mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 05:32:12 -03:00
Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac7d23011c | ||
|
|
491e09b7b5 | ||
|
|
192bc237bf | ||
|
|
f041f4a114 | ||
|
|
2546580377 | ||
|
|
8fbf2ab56d | ||
|
|
ea727aad2e | ||
|
|
5520aecbba | ||
|
|
6b738a4769 | ||
|
|
903a8050b3 | ||
|
|
31b032429d | ||
|
|
2bcf341f04 | ||
|
|
ca6f45b359 | ||
|
|
2a67cec16b | ||
|
|
1800afe31b | ||
|
|
91801dff85 | ||
|
|
be594133f0 | ||
|
|
8a538d117e | ||
|
|
8d9118cbee | ||
|
|
b67464ea13 | ||
|
|
33334da0bb | ||
|
|
40ce2baa7b | ||
|
|
1134466cc0 | ||
|
|
92341111ad | ||
|
|
4956d6781f | ||
|
|
63562240c4 | ||
|
|
84d801cf14 | ||
|
|
b56fe4ca68 | ||
|
|
6c83c65e02 | ||
|
|
a83f020fcc | ||
|
|
7f9a3bf272 | ||
|
|
f80e266d02 | ||
|
|
7bef562541 | ||
|
|
b2428f607c | ||
|
|
8303196b57 | ||
|
|
987b8c8742 | ||
|
|
e60a579b85 | ||
|
|
be8edafed0 | ||
|
|
a258a18fa4 | ||
|
|
59010ca431 | ||
|
|
75f3764e6c | ||
|
|
867ffd1163 | ||
|
|
6acccbbb94 | ||
|
|
b2c4efab45 | ||
|
|
408a435b71 | ||
|
|
36d3cd93d5 | ||
|
|
b36fea002e | ||
|
|
52acbd954a | ||
|
|
f6709a55c3 | ||
|
|
7b374d747b | ||
|
|
fd480a9360 | ||
|
|
ec8b228867 | ||
|
|
401200050b | ||
|
|
29160bd6e5 | ||
|
|
3c9e402bc0 | ||
|
|
ff4d0f0208 | ||
|
|
f82908221c | ||
|
|
4246908f2e | ||
|
|
f64597afd2 | ||
|
|
975ff2672d | ||
|
|
e90ba31784 | ||
|
|
a4074c93bc | ||
|
|
7a8b7598c7 | ||
|
|
cd0d832f14 | ||
|
|
5b0becaaf2 | ||
|
|
9817bac2fe | ||
|
|
f6bd48cfcd | ||
|
|
01843b8f2b | ||
|
|
94ed81de5e | ||
|
|
0700b8f399 | ||
|
|
d62cff9841 | ||
|
|
083f4805b2 | ||
|
|
8e5bfd379e | ||
|
|
2366f143d8 | ||
|
|
e997f5bc1b | ||
|
|
842beec7cc | ||
|
|
d2268fc9e0 | ||
|
|
a98e26139f | ||
|
|
522a3ea88b | ||
|
|
d7949fbc30 | ||
|
|
6df083a1d5 | ||
|
|
4dc80e7f6e | ||
|
|
c2a8508513 | ||
|
|
159193ef43 | ||
|
|
1f37ffb105 | ||
|
|
919fed05c5 | ||
|
|
1814f83bee | ||
|
|
1823840456 | ||
|
|
623c28bfc3 |
1
.github/copilot-instructions.md
vendored
Normal file
1
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Always use English for comments.
|
||||
@@ -34,6 +34,13 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
||||
|
||||
## Release Notes
|
||||
|
||||
### v0.9.0
|
||||
* **UI Overhaul for Enhanced Navigation** - Replaced the top flat folder tags with a new folder sidebar and breadcrumb navigation system for a more intuitive folder browsing and selection experience.
|
||||
* **Dual-Mode Folder Sidebar** - The new folder sidebar offers two display modes: 'List Mode,' which mirrors the classic folder view, and 'Tree Mode,' which presents a hierarchical folder structure for effortless navigation through nested directories.
|
||||
* **Internationalization Support** - Introduced multi-language support, now available in English, Simplified Chinese, Traditional Chinese, Spanish, Japanese, Korean, French, Russian, and German. Feedback from native speakers is welcome to improve the translations.
|
||||
* **Automatic Filename Conflict Resolution** - Implemented automatic file renaming (`original name + short hash`) to prevent conflicts when downloading or moving models.
|
||||
* **Performance Optimizations & Bug Fixes** - Various performance improvements and bug fixes for a more stable and responsive experience.
|
||||
|
||||
### v0.8.30
|
||||
* **Automatic Model Path Correction** - Added auto-correction for model paths in built-in nodes such as Load Checkpoint, Load Diffusion Model, Load LoRA, and other custom nodes with similar functionality. Workflows containing outdated or incorrect model paths will now be automatically updated to reflect the current location of your models.
|
||||
* **Node UI Enhancements** - Improved node interface for a smoother and more intuitive user experience.
|
||||
|
||||
170
i18n_migration_summary.md
Normal file
170
i18n_migration_summary.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# i18n System Migration Complete
|
||||
|
||||
## 概要 (Summary)
|
||||
|
||||
成功完成了从JavaScript ES6模块到JSON格式的国际化系统迁移,包含完整的多语言翻译和代码更新。
|
||||
|
||||
Successfully completed the migration from JavaScript ES6 modules to JSON format for the internationalization system, including complete multilingual translations and code updates.
|
||||
|
||||
## 完成的工作 (Completed Work)
|
||||
|
||||
### 1. 文件结构重组 (File Structure Reorganization)
|
||||
- **新建目录**: `/locales/` - 集中存放所有JSON翻译文件
|
||||
- **移除目录**: `/static/js/i18n/locales/` - 删除了旧的JavaScript文件
|
||||
|
||||
### 2. 格式转换 (Format Conversion)
|
||||
- **转换前**: ES6模块格式 (`export const en = { ... }`)
|
||||
- **转换后**: 标准JSON格式 (`{ ... }`)
|
||||
- **支持语言**: 9种语言完全转换
|
||||
- English (en)
|
||||
- 简体中文 (zh-CN)
|
||||
- 繁體中文 (zh-TW)
|
||||
- 日本語 (ja)
|
||||
- Русский (ru)
|
||||
- Deutsch (de)
|
||||
- Français (fr)
|
||||
- Español (es)
|
||||
- 한국어 (ko)
|
||||
|
||||
### 3. 翻译完善 (Translation Completion)
|
||||
- **翻译条目**: 每种语言386个翻译键值对
|
||||
- **覆盖范围**: 完整覆盖所有UI元素
|
||||
- **质量保证**: 所有翻译键在各语言间保持一致
|
||||
|
||||
### 4. JavaScript代码更新 (JavaScript Code Updates)
|
||||
|
||||
#### 主要修改文件: `static/js/i18n/index.js`
|
||||
```javascript
|
||||
// 旧版本: 静态导入
|
||||
import { en } from './locales/en.js';
|
||||
|
||||
// 新版本: 动态JSON加载
|
||||
async loadLocale(locale) {
|
||||
const response = await fetch(`/locales/${locale}.json`);
|
||||
return await response.json();
|
||||
}
|
||||
```
|
||||
|
||||
#### 核心功能更新:
|
||||
- **构造函数**: 从静态导入改为配置驱动
|
||||
- **语言加载**: 异步JSON获取机制
|
||||
- **初始化**: 支持Promise-based的异步初始化
|
||||
- **错误处理**: 增强的回退机制到英语
|
||||
- **向后兼容**: 保持现有API接口不变
|
||||
|
||||
### 5. Python服务端更新 (Python Server-side Updates)
|
||||
|
||||
#### 修改文件: `py/services/server_i18n.py`
|
||||
```python
|
||||
# 旧版本: 解析JavaScript文件
|
||||
def _load_locale_file(self, path, filename, locale_code):
|
||||
# 复杂的JS到JSON转换逻辑
|
||||
|
||||
# 新版本: 直接加载JSON
|
||||
def _load_locale_file(self, path, filename, locale_code):
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
translations = json.load(f)
|
||||
```
|
||||
|
||||
#### 路径更新:
|
||||
- **旧路径**: `static/js/i18n/locales/*.js`
|
||||
- **新路径**: `locales/*.json`
|
||||
|
||||
### 6. 服务器路由配置 (Server Route Configuration)
|
||||
|
||||
#### 修改文件: `standalone.py`
|
||||
```python
|
||||
# 新增静态路由服务JSON文件
|
||||
app.router.add_static('/locales', locales_path)
|
||||
```
|
||||
|
||||
## 技术架构 (Technical Architecture)
|
||||
|
||||
### 前端 (Frontend)
|
||||
```
|
||||
Browser → JavaScript i18n Manager → fetch('/locales/{lang}.json') → JSON Response
|
||||
```
|
||||
|
||||
### 后端 (Backend)
|
||||
```
|
||||
Python Server → ServerI18nManager → Direct JSON loading → Template Rendering
|
||||
```
|
||||
|
||||
### 文件组织 (File Organization)
|
||||
```
|
||||
ComfyUI-Lora-Manager/
|
||||
├── locales/ # 新的JSON翻译文件目录
|
||||
│ ├── en.json # 英语翻译 (基准)
|
||||
│ ├── zh-CN.json # 简体中文翻译
|
||||
│ ├── zh-TW.json # 繁体中文翻译
|
||||
│ ├── ja.json # 日语翻译
|
||||
│ ├── ru.json # 俄语翻译
|
||||
│ ├── de.json # 德语翻译
|
||||
│ ├── fr.json # 法语翻译
|
||||
│ ├── es.json # 西班牙语翻译
|
||||
│ └── ko.json # 韩语翻译
|
||||
├── static/js/i18n/
|
||||
│ └── index.js # 更新的JavaScript i18n管理器
|
||||
└── py/services/
|
||||
└── server_i18n.py # 更新的Python服务端i18n
|
||||
```
|
||||
|
||||
## 测试验证 (Testing & Validation)
|
||||
|
||||
### 测试脚本: `test_i18n.py`
|
||||
```bash
|
||||
🚀 Testing updated i18n system...
|
||||
✅ All JSON locale files are valid (9 languages)
|
||||
✅ Server-side i18n system working correctly
|
||||
✅ All languages have complete translations (386 keys each)
|
||||
🎉 All tests passed!
|
||||
```
|
||||
|
||||
### 验证内容:
|
||||
1. **JSON文件完整性**: 所有文件格式正确,语法有效
|
||||
2. **翻译完整性**: 各语言翻译键值一致,无缺失
|
||||
3. **服务端功能**: Python i18n服务正常加载和翻译
|
||||
4. **参数插值**: 动态参数替换功能正常
|
||||
|
||||
## 优势与改进 (Benefits & Improvements)
|
||||
|
||||
### 1. 维护性提升
|
||||
- **简化格式**: JSON比JavaScript对象更易于编辑和维护
|
||||
- **工具支持**: 更好的编辑器语法高亮和验证支持
|
||||
- **版本控制**: 更清晰的diff显示,便于追踪更改
|
||||
|
||||
### 2. 性能优化
|
||||
- **按需加载**: 只加载当前所需语言,减少初始加载时间
|
||||
- **缓存友好**: JSON文件可以被浏览器和CDN更好地缓存
|
||||
- **压缩效率**: JSON格式压缩率通常更高
|
||||
|
||||
### 3. 开发体验
|
||||
- **动态切换**: 支持运行时语言切换,无需重新加载页面
|
||||
- **易于扩展**: 添加新语言只需增加JSON文件
|
||||
- **调试友好**: 更容易定位翻译问题和缺失键
|
||||
|
||||
### 4. 部署便利
|
||||
- **静态资源**: JSON文件可以作为静态资源部署
|
||||
- **CDN支持**: 可以通过CDN分发翻译文件
|
||||
- **版本管理**: 更容易管理不同版本的翻译
|
||||
|
||||
## 兼容性保证 (Compatibility Assurance)
|
||||
|
||||
- **API兼容**: 所有现有的JavaScript API保持不变
|
||||
- **调用方式**: 现有代码无需修改即可工作
|
||||
- **错误处理**: 增强的回退机制确保用户体验
|
||||
- **性能**: 新系统性能与旧系统相当或更好
|
||||
|
||||
## 后续建议 (Future Recommendations)
|
||||
|
||||
1. **监控**: 部署后监控翻译加载性能和错误率
|
||||
2. **优化**: 考虑实施翻译缓存策略以进一步提升性能
|
||||
3. **扩展**: 可以考虑添加翻译管理界面,便于非技术人员更新翻译
|
||||
4. **自动化**: 实施CI/CD流程自动验证翻译完整性
|
||||
|
||||
---
|
||||
|
||||
**迁移完成时间**: 2024年
|
||||
**影响文件数量**: 21个文件 (9个新JSON + 2个JS更新 + 1个Python更新 + 1个服务器配置)
|
||||
**翻译键总数**: 386个 × 9种语言 = 3,474个翻译条目
|
||||
**测试状态**: ✅ 全部通过
|
||||
1128
locales/de.json
Normal file
1128
locales/de.json
Normal file
File diff suppressed because it is too large
Load Diff
1128
locales/en.json
Normal file
1128
locales/en.json
Normal file
File diff suppressed because it is too large
Load Diff
1128
locales/es.json
Normal file
1128
locales/es.json
Normal file
File diff suppressed because it is too large
Load Diff
1128
locales/fr.json
Normal file
1128
locales/fr.json
Normal file
File diff suppressed because it is too large
Load Diff
1128
locales/ja.json
Normal file
1128
locales/ja.json
Normal file
File diff suppressed because it is too large
Load Diff
1128
locales/ko.json
Normal file
1128
locales/ko.json
Normal file
File diff suppressed because it is too large
Load Diff
1128
locales/ru.json
Normal file
1128
locales/ru.json
Normal file
File diff suppressed because it is too large
Load Diff
1128
locales/zh-CN.json
Normal file
1128
locales/zh-CN.json
Normal file
File diff suppressed because it is too large
Load Diff
1128
locales/zh-TW.json
Normal file
1128
locales/zh-TW.json
Normal file
File diff suppressed because it is too large
Load Diff
24
py/config.py
24
py/config.py
@@ -18,6 +18,7 @@ class Config:
|
||||
def __init__(self):
|
||||
self.templates_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'templates')
|
||||
self.static_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static')
|
||||
self.i18n_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'locales')
|
||||
# Path mapping dictionary, target to link mapping
|
||||
self._path_mappings = {}
|
||||
# Static route mapping dictionary, target to route mapping
|
||||
@@ -268,19 +269,26 @@ class Config:
|
||||
return []
|
||||
|
||||
def get_preview_static_url(self, preview_path: str) -> str:
|
||||
"""Convert local preview path to static URL"""
|
||||
if not preview_path:
|
||||
return ""
|
||||
|
||||
real_path = os.path.realpath(preview_path).replace(os.sep, '/')
|
||||
|
||||
|
||||
# Find longest matching path (most specific match)
|
||||
best_match = ""
|
||||
best_route = ""
|
||||
|
||||
for path, route in self._route_mappings.items():
|
||||
if real_path.startswith(path):
|
||||
relative_path = os.path.relpath(real_path, path).replace(os.sep, '/')
|
||||
safe_parts = [urllib.parse.quote(part) for part in relative_path.split('/')]
|
||||
safe_path = '/'.join(safe_parts)
|
||||
return f'{route}/{safe_path}'
|
||||
|
||||
if real_path.startswith(path) and len(path) > len(best_match):
|
||||
best_match = path
|
||||
best_route = route
|
||||
|
||||
if best_match:
|
||||
relative_path = os.path.relpath(real_path, best_match).replace(os.sep, '/')
|
||||
safe_parts = [urllib.parse.quote(part) for part in relative_path.split('/')]
|
||||
safe_path = '/'.join(safe_parts)
|
||||
return f'{best_route}/{safe_path}'
|
||||
|
||||
return ""
|
||||
|
||||
# Global config instance
|
||||
|
||||
@@ -145,7 +145,12 @@ class LoraManager:
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to add static route on initialization for {target_path}: {e}")
|
||||
continue
|
||||
|
||||
|
||||
# Add static route for locales JSON files
|
||||
if os.path.exists(config.i18n_path):
|
||||
app.router.add_static('/locales', config.i18n_path)
|
||||
logger.info(f"Added static route for locales: /locales -> {config.i18n_path}")
|
||||
|
||||
# Add static route for plugin assets
|
||||
app.router.add_static('/loras_static', config.static_path)
|
||||
|
||||
@@ -198,18 +203,149 @@ class LoraManager:
|
||||
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
|
||||
|
||||
# Create low-priority initialization tasks
|
||||
asyncio.create_task(lora_scanner.initialize_in_background(), name='lora_cache_init')
|
||||
asyncio.create_task(checkpoint_scanner.initialize_in_background(), name='checkpoint_cache_init')
|
||||
asyncio.create_task(embedding_scanner.initialize_in_background(), name='embedding_cache_init')
|
||||
asyncio.create_task(recipe_scanner.initialize_in_background(), name='recipe_cache_init')
|
||||
init_tasks = [
|
||||
asyncio.create_task(lora_scanner.initialize_in_background(), name='lora_cache_init'),
|
||||
asyncio.create_task(checkpoint_scanner.initialize_in_background(), name='checkpoint_cache_init'),
|
||||
asyncio.create_task(embedding_scanner.initialize_in_background(), name='embedding_cache_init'),
|
||||
asyncio.create_task(recipe_scanner.initialize_in_background(), name='recipe_cache_init')
|
||||
]
|
||||
|
||||
await ExampleImagesMigration.check_and_run_migrations()
|
||||
|
||||
# Schedule post-initialization tasks to run after scanners complete
|
||||
asyncio.create_task(
|
||||
cls._run_post_initialization_tasks(init_tasks),
|
||||
name='post_init_tasks'
|
||||
)
|
||||
|
||||
logger.info("LoRA Manager: All services initialized and background tasks scheduled")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LoRA Manager: Error initializing services: {e}", exc_info=True)
|
||||
|
||||
@classmethod
|
||||
async def _run_post_initialization_tasks(cls, init_tasks):
|
||||
"""Run post-initialization tasks after all scanners complete"""
|
||||
try:
|
||||
logger.debug("LoRA Manager: Waiting for scanner initialization to complete...")
|
||||
|
||||
# Wait for all scanner initialization tasks to complete
|
||||
await asyncio.gather(*init_tasks, return_exceptions=True)
|
||||
|
||||
logger.debug("LoRA Manager: Scanner initialization completed, starting post-initialization tasks...")
|
||||
|
||||
# Run post-initialization tasks
|
||||
post_tasks = [
|
||||
asyncio.create_task(cls._cleanup_backup_files(), name='cleanup_bak_files'),
|
||||
# Add more post-initialization tasks here as needed
|
||||
# asyncio.create_task(cls._another_post_task(), name='another_task'),
|
||||
]
|
||||
|
||||
# Run all post-initialization tasks
|
||||
results = await asyncio.gather(*post_tasks, return_exceptions=True)
|
||||
|
||||
# Log results
|
||||
for i, result in enumerate(results):
|
||||
task_name = post_tasks[i].get_name()
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"Post-initialization task '{task_name}' failed: {result}")
|
||||
else:
|
||||
logger.debug(f"Post-initialization task '{task_name}' completed successfully")
|
||||
|
||||
logger.debug("LoRA Manager: All post-initialization tasks completed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LoRA Manager: Error in post-initialization tasks: {e}", exc_info=True)
|
||||
|
||||
@classmethod
|
||||
async def _cleanup_backup_files(cls):
|
||||
"""Clean up .bak files in all model roots"""
|
||||
try:
|
||||
logger.debug("Starting cleanup of .bak files in model directories...")
|
||||
|
||||
# Collect all model roots
|
||||
all_roots = set()
|
||||
all_roots.update(config.loras_roots)
|
||||
all_roots.update(config.base_models_roots)
|
||||
all_roots.update(config.embeddings_roots)
|
||||
|
||||
total_deleted = 0
|
||||
total_size_freed = 0
|
||||
|
||||
for root_path in all_roots:
|
||||
if not os.path.exists(root_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
deleted_count, size_freed = await cls._cleanup_backup_files_in_directory(root_path)
|
||||
total_deleted += deleted_count
|
||||
total_size_freed += size_freed
|
||||
|
||||
if deleted_count > 0:
|
||||
logger.debug(f"Cleaned up {deleted_count} .bak files in {root_path} (freed {size_freed / (1024*1024):.2f} MB)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up .bak files in {root_path}: {e}")
|
||||
|
||||
# Yield control periodically
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
if total_deleted > 0:
|
||||
logger.debug(f"Backup cleanup completed: removed {total_deleted} .bak files, freed {total_size_freed / (1024*1024):.2f} MB total")
|
||||
else:
|
||||
logger.debug("Backup cleanup completed: no .bak files found")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during backup file cleanup: {e}", exc_info=True)
|
||||
|
||||
@classmethod
|
||||
async def _cleanup_backup_files_in_directory(cls, directory_path: str):
|
||||
"""Clean up .bak files in a specific directory recursively
|
||||
|
||||
Args:
|
||||
directory_path: Path to the directory to clean
|
||||
|
||||
Returns:
|
||||
Tuple[int, int]: (number of files deleted, total size freed in bytes)
|
||||
"""
|
||||
deleted_count = 0
|
||||
size_freed = 0
|
||||
visited_paths = set()
|
||||
|
||||
def cleanup_recursive(path):
|
||||
nonlocal deleted_count, size_freed
|
||||
|
||||
try:
|
||||
real_path = os.path.realpath(path)
|
||||
if real_path in visited_paths:
|
||||
return
|
||||
visited_paths.add(real_path)
|
||||
|
||||
with os.scandir(path) as it:
|
||||
for entry in it:
|
||||
try:
|
||||
if entry.is_file(follow_symlinks=True) and entry.name.endswith('.bak'):
|
||||
file_size = entry.stat().st_size
|
||||
os.remove(entry.path)
|
||||
deleted_count += 1
|
||||
size_freed += file_size
|
||||
logger.debug(f"Deleted .bak file: {entry.path}")
|
||||
|
||||
elif entry.is_dir(follow_symlinks=True):
|
||||
cleanup_recursive(entry.path)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not delete .bak file {entry.path}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error scanning directory {path} for .bak files: {e}")
|
||||
|
||||
# Run the recursive cleanup in a thread pool to avoid blocking
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, cleanup_recursive, directory_path)
|
||||
|
||||
return deleted_count, size_freed
|
||||
|
||||
@classmethod
|
||||
async def _cleanup(cls, app):
|
||||
"""Cleanup resources using ServiceRegistry"""
|
||||
|
||||
@@ -11,6 +11,7 @@ import jinja2
|
||||
from ..utils.routes_common import ModelRouteUtils
|
||||
from ..services.websocket_manager import ws_manager
|
||||
from ..services.settings_manager import settings
|
||||
from ..services.server_i18n import server_i18n
|
||||
from ..utils.utils import calculate_relative_path_for_model
|
||||
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
||||
from ..config import config
|
||||
@@ -69,6 +70,8 @@ class BaseModelRoutes(ABC):
|
||||
app.router.add_get(f'/api/{prefix}/get-notes', self.get_model_notes)
|
||||
app.router.add_get(f'/api/{prefix}/preview-url', self.get_model_preview_url)
|
||||
app.router.add_get(f'/api/{prefix}/civitai-url', self.get_model_civitai_url)
|
||||
app.router.add_get(f'/api/{prefix}/metadata', self.get_model_metadata)
|
||||
app.router.add_get(f'/api/{prefix}/model-description', self.get_model_description)
|
||||
|
||||
# Autocomplete route
|
||||
app.router.add_get(f'/api/{prefix}/relative-paths', self.get_relative_paths)
|
||||
@@ -111,30 +114,36 @@ class BaseModelRoutes(ABC):
|
||||
if not self.template_env or not template_name:
|
||||
return web.Response(text="Template environment or template name not set", status=500)
|
||||
|
||||
if is_initializing:
|
||||
rendered = self.template_env.get_template(template_name).render(
|
||||
folders=[],
|
||||
is_initializing=True,
|
||||
settings=settings,
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
# Get user's language setting
|
||||
user_language = settings.get('language', 'en')
|
||||
|
||||
# Set server-side i18n locale
|
||||
server_i18n.set_locale(user_language)
|
||||
|
||||
# Add i18n filter to the template environment if not already added
|
||||
if not hasattr(self.template_env, '_i18n_filter_added'):
|
||||
self.template_env.filters['t'] = server_i18n.create_template_filter()
|
||||
self.template_env._i18n_filter_added = True
|
||||
|
||||
# Prepare template context
|
||||
template_context = {
|
||||
'is_initializing': is_initializing,
|
||||
'settings': settings,
|
||||
'request': request,
|
||||
'folders': [],
|
||||
't': server_i18n.get_translation,
|
||||
}
|
||||
|
||||
if not is_initializing:
|
||||
try:
|
||||
cache = await self.service.scanner.get_cached_data(force_refresh=False)
|
||||
rendered = self.template_env.get_template(template_name).render(
|
||||
folders=getattr(cache, "folders", []),
|
||||
is_initializing=False,
|
||||
settings=settings,
|
||||
request=request
|
||||
)
|
||||
template_context['folders'] = getattr(cache, "folders", [])
|
||||
except Exception as cache_error:
|
||||
logger.error(f"Error loading cache data: {cache_error}")
|
||||
rendered = self.template_env.get_template(template_name).render(
|
||||
folders=[],
|
||||
is_initializing=True,
|
||||
settings=settings,
|
||||
request=request
|
||||
)
|
||||
template_context['is_initializing'] = True
|
||||
|
||||
rendered = self.template_env.get_template(template_name).render(**template_context)
|
||||
|
||||
return web.Response(
|
||||
text=rendered,
|
||||
content_type='text/html'
|
||||
@@ -191,7 +200,7 @@ class BaseModelRoutes(ABC):
|
||||
'modelname': request.query.get('search_modelname', 'true').lower() == 'true',
|
||||
'tags': request.query.get('search_tags', 'false').lower() == 'true',
|
||||
'creator': request.query.get('search_creator', 'false').lower() == 'true',
|
||||
'recursive': request.query.get('recursive', 'false').lower() == 'true',
|
||||
'recursive': request.query.get('recursive', 'true').lower() == 'true',
|
||||
}
|
||||
|
||||
# Parse hash filters if provided
|
||||
@@ -684,19 +693,27 @@ class BaseModelRoutes(ABC):
|
||||
source_dir = os.path.dirname(file_path)
|
||||
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||
logger.info(f"Source and target directories are the same: {source_dir}")
|
||||
return web.json_response({'success': True, 'message': 'Source and target directories are the same'})
|
||||
file_name = os.path.basename(file_path)
|
||||
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
||||
if os.path.exists(target_file_path):
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f"Target file already exists: {target_file_path}"
|
||||
}, status=409)
|
||||
success = await self.service.scanner.move_model(file_path, target_path)
|
||||
if success:
|
||||
return web.json_response({'success': True, 'new_file_path': target_file_path})
|
||||
'success': True,
|
||||
'message': 'Source and target directories are the same',
|
||||
'original_file_path': file_path,
|
||||
'new_file_path': file_path
|
||||
})
|
||||
|
||||
new_file_path = await self.service.scanner.move_model(file_path, target_path)
|
||||
if new_file_path:
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'original_file_path': file_path,
|
||||
'new_file_path': new_file_path
|
||||
})
|
||||
else:
|
||||
return web.Response(text='Failed to move model', status=500)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Failed to move model',
|
||||
'original_file_path': file_path,
|
||||
'new_file_path': None
|
||||
}, status=500)
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving model: {e}", exc_info=True)
|
||||
return web.Response(text=str(e), status=500)
|
||||
@@ -715,26 +732,28 @@ class BaseModelRoutes(ABC):
|
||||
source_dir = os.path.dirname(file_path)
|
||||
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||
results.append({
|
||||
"path": file_path,
|
||||
"original_file_path": file_path,
|
||||
"new_file_path": file_path,
|
||||
"success": True,
|
||||
"message": "Source and target directories are the same"
|
||||
})
|
||||
continue
|
||||
file_name = os.path.basename(file_path)
|
||||
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
||||
if os.path.exists(target_file_path):
|
||||
|
||||
new_file_path = await self.service.scanner.move_model(file_path, target_path)
|
||||
if new_file_path:
|
||||
results.append({
|
||||
"path": file_path,
|
||||
"success": False,
|
||||
"message": f"Target file already exists: {target_file_path}"
|
||||
"original_file_path": file_path,
|
||||
"new_file_path": new_file_path,
|
||||
"success": True,
|
||||
"message": "Success"
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
"original_file_path": file_path,
|
||||
"new_file_path": None,
|
||||
"success": False,
|
||||
"message": "Failed to move model"
|
||||
})
|
||||
continue
|
||||
success = await self.service.scanner.move_model(file_path, target_path)
|
||||
results.append({
|
||||
"path": file_path,
|
||||
"success": success,
|
||||
"message": "Success" if success else "Failed to move model"
|
||||
})
|
||||
success_count = sum(1 for r in results if r["success"])
|
||||
failure_count = len(results) - success_count
|
||||
return web.json_response({
|
||||
@@ -1128,6 +1147,58 @@ class BaseModelRoutes(ABC):
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
async def get_model_metadata(self, request: web.Request) -> web.Response:
|
||||
"""Get filtered CivitAI metadata for a model by file path"""
|
||||
try:
|
||||
file_path = request.query.get('file_path')
|
||||
if not file_path:
|
||||
return web.Response(text='File path is required', status=400)
|
||||
|
||||
metadata = await self.service.get_model_metadata(file_path)
|
||||
if metadata is not None:
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'metadata': metadata
|
||||
})
|
||||
else:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f'{self.model_type.capitalize()} not found or no CivitAI metadata available'
|
||||
}, status=404)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting {self.model_type} metadata: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
async def get_model_description(self, request: web.Request) -> web.Response:
|
||||
"""Get model description by file path"""
|
||||
try:
|
||||
file_path = request.query.get('file_path')
|
||||
if not file_path:
|
||||
return web.Response(text='File path is required', status=400)
|
||||
|
||||
description = await self.service.get_model_description(file_path)
|
||||
if description is not None:
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'description': description
|
||||
})
|
||||
else:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f'{self.model_type.capitalize()} not found or no description available'
|
||||
}, status=404)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting {self.model_type} description: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
async def get_relative_paths(self, request: web.Request) -> web.Response:
|
||||
"""Get model relative file paths for autocomplete functionality"""
|
||||
try:
|
||||
|
||||
@@ -44,7 +44,6 @@ class LoraRoutes(BaseModelRoutes):
|
||||
# LoRA-specific query routes
|
||||
app.router.add_get(f'/api/{prefix}/letter-counts', self.get_letter_counts)
|
||||
app.router.add_get(f'/api/{prefix}/get-trigger-words', self.get_lora_trigger_words)
|
||||
app.router.add_get(f'/api/{prefix}/model-description', self.get_lora_model_description)
|
||||
app.router.add_get(f'/api/{prefix}/usage-tips-by-path', self.get_lora_usage_tips_by_path)
|
||||
|
||||
# CivitAI integration with LoRA-specific validation
|
||||
@@ -298,74 +297,6 @@ class LoraRoutes(BaseModelRoutes):
|
||||
"error": str(e)
|
||||
}, status=500)
|
||||
|
||||
async def get_lora_model_description(self, request: web.Request) -> web.Response:
|
||||
"""Get model description for a Lora model"""
|
||||
try:
|
||||
# Get parameters
|
||||
model_id = request.query.get('model_id')
|
||||
file_path = request.query.get('file_path')
|
||||
|
||||
if not model_id:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Model ID is required'
|
||||
}, status=400)
|
||||
|
||||
# Check if we already have the description stored in metadata
|
||||
description = None
|
||||
tags = []
|
||||
creator = {}
|
||||
if file_path:
|
||||
import os
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
|
||||
description = metadata.get('modelDescription')
|
||||
tags = metadata.get('tags', [])
|
||||
creator = metadata.get('creator', {})
|
||||
|
||||
# If description is not in metadata, fetch from CivitAI
|
||||
if not description:
|
||||
logger.info(f"Fetching model metadata for model ID: {model_id}")
|
||||
model_metadata, _ = await self.civitai_client.get_model_metadata(model_id)
|
||||
|
||||
if model_metadata:
|
||||
description = model_metadata.get('description')
|
||||
tags = model_metadata.get('tags', [])
|
||||
creator = model_metadata.get('creator', {})
|
||||
|
||||
# Save the metadata to file if we have a file path and got metadata
|
||||
if file_path:
|
||||
try:
|
||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
|
||||
|
||||
metadata['modelDescription'] = description
|
||||
metadata['tags'] = tags
|
||||
# Ensure the civitai dict exists
|
||||
if 'civitai' not in metadata:
|
||||
metadata['civitai'] = {}
|
||||
# Store creator in the civitai nested structure
|
||||
metadata['civitai']['creator'] = creator
|
||||
|
||||
await MetadataManager.save_metadata(file_path, metadata, True)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving model metadata: {e}")
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'description': description or "<p>No model description available.</p>",
|
||||
'tags': tags,
|
||||
'creator': creator
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting model metadata: {e}")
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
async def get_trigger_words(self, request: web.Request) -> web.Response:
|
||||
"""Get trigger words for specified LoRA models"""
|
||||
try:
|
||||
|
||||
@@ -17,6 +17,7 @@ from ..recipes import RecipeParserFactory
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH
|
||||
|
||||
from ..services.settings_manager import settings
|
||||
from ..services.server_i18n import server_i18n
|
||||
from ..config import config
|
||||
|
||||
# Check if running in standalone mode
|
||||
@@ -127,6 +128,17 @@ class RecipeRoutes:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
|
||||
# 获取用户语言设置
|
||||
user_language = settings.get('language', 'en')
|
||||
|
||||
# 设置服务端i18n语言
|
||||
server_i18n.set_locale(user_language)
|
||||
|
||||
# 为模板环境添加i18n过滤器
|
||||
if not hasattr(self.template_env, '_i18n_filter_added'):
|
||||
self.template_env.filters['t'] = server_i18n.create_template_filter()
|
||||
self.template_env._i18n_filter_added = True
|
||||
|
||||
# Skip initialization check and directly try to get cached data
|
||||
try:
|
||||
# Recipe scanner will initialize cache if needed
|
||||
@@ -136,7 +148,9 @@ class RecipeRoutes:
|
||||
recipes=[], # Frontend will load recipes via API
|
||||
is_initializing=False,
|
||||
settings=settings,
|
||||
request=request
|
||||
request=request,
|
||||
# 添加服务端翻译函数
|
||||
t=server_i18n.get_translation,
|
||||
)
|
||||
except Exception as cache_error:
|
||||
logger.error(f"Error loading recipe cache data: {cache_error}")
|
||||
@@ -145,7 +159,9 @@ class RecipeRoutes:
|
||||
rendered = template.render(
|
||||
is_initializing=True,
|
||||
settings=settings,
|
||||
request=request
|
||||
request=request,
|
||||
# 添加服务端翻译函数
|
||||
t=server_i18n.get_translation,
|
||||
)
|
||||
logger.info("Recipe cache error, returning initialization page")
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Dict, List, Any
|
||||
|
||||
from ..config import config
|
||||
from ..services.settings_manager import settings
|
||||
from ..services.server_i18n import server_i18n
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..utils.usage_stats import UsageStats
|
||||
|
||||
@@ -58,11 +59,23 @@ class StatsRoutes:
|
||||
|
||||
is_initializing = lora_initializing or checkpoint_initializing or embedding_initializing
|
||||
|
||||
# 获取用户语言设置
|
||||
user_language = settings.get('language', 'en')
|
||||
|
||||
# 设置服务端i18n语言
|
||||
server_i18n.set_locale(user_language)
|
||||
|
||||
# 为模板环境添加i18n过滤器
|
||||
if not hasattr(self.template_env, '_i18n_filter_added'):
|
||||
self.template_env.filters['t'] = server_i18n.create_template_filter()
|
||||
self.template_env._i18n_filter_added = True
|
||||
|
||||
template = self.template_env.get_template('statistics.html')
|
||||
rendered = template.render(
|
||||
is_initializing=is_initializing,
|
||||
settings=settings,
|
||||
request=request
|
||||
request=request,
|
||||
t=server_i18n.get_translation,
|
||||
)
|
||||
|
||||
return web.Response(
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
import os
|
||||
|
||||
from ..utils.models import BaseModelMetadata
|
||||
from ..utils.routes_common import ModelRouteUtils
|
||||
from ..utils.constants import NSFW_LEVELS
|
||||
from .settings_manager import settings
|
||||
from ..utils.utils import fuzzy_match
|
||||
@@ -68,7 +69,7 @@ class BaseModelService(ABC):
|
||||
'filename': True,
|
||||
'modelname': True,
|
||||
'tags': False,
|
||||
'recursive': False,
|
||||
'recursive': True,
|
||||
}
|
||||
|
||||
# Get the base data set using new sort logic
|
||||
@@ -139,12 +140,20 @@ class BaseModelService(ABC):
|
||||
|
||||
# Apply folder filtering
|
||||
if folder is not None:
|
||||
if search_options and search_options.get('recursive', False):
|
||||
if search_options and search_options.get('recursive', True):
|
||||
# Recursive folder filtering - include all subfolders
|
||||
data = [
|
||||
item for item in data
|
||||
if item['folder'].startswith(folder)
|
||||
]
|
||||
# Ensure we match exact folder or its subfolders by checking path boundaries
|
||||
if folder == "":
|
||||
# Empty folder means root - include all items
|
||||
pass # Don't filter anything
|
||||
else:
|
||||
# Add trailing slash to ensure we match folder boundaries correctly
|
||||
folder_with_separator = folder + "/"
|
||||
data = [
|
||||
item for item in data
|
||||
if (item['folder'] == folder or
|
||||
item['folder'].startswith(folder_with_separator))
|
||||
]
|
||||
else:
|
||||
# Exact folder filtering
|
||||
data = [
|
||||
@@ -379,6 +388,26 @@ class BaseModelService(ABC):
|
||||
|
||||
return {'civitai_url': None, 'model_id': None, 'version_id': None}
|
||||
|
||||
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
|
||||
"""Get filtered CivitAI metadata for a model by file path"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
for model in cache.raw_data:
|
||||
if model.get('file_path') == file_path:
|
||||
return ModelRouteUtils.filter_civitai_data(model.get("civitai", {}))
|
||||
|
||||
return None
|
||||
|
||||
async def get_model_description(self, file_path: str) -> Optional[str]:
|
||||
"""Get model description by file path"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
for model in cache.raw_data:
|
||||
if model.get('file_path') == file_path:
|
||||
return model.get('modelDescription', '')
|
||||
|
||||
return None
|
||||
|
||||
async def search_relative_paths(self, search_term: str, limit: int = 15) -> List[str]:
|
||||
"""Search model relative file paths for autocomplete functionality"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
@@ -34,12 +34,11 @@ class CheckpointService(BaseModelService):
|
||||
"file_size": checkpoint_data.get("size", 0),
|
||||
"modified": checkpoint_data.get("modified", ""),
|
||||
"tags": checkpoint_data.get("tags", []),
|
||||
"modelDescription": checkpoint_data.get("modelDescription", ""),
|
||||
"from_civitai": checkpoint_data.get("from_civitai", True),
|
||||
"notes": checkpoint_data.get("notes", ""),
|
||||
"model_type": checkpoint_data.get("model_type", "checkpoint"),
|
||||
"favorite": checkpoint_data.get("favorite", False),
|
||||
"civitai": ModelRouteUtils.filter_civitai_data(checkpoint_data.get("civitai", {}))
|
||||
"civitai": ModelRouteUtils.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
||||
}
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict:
|
||||
|
||||
@@ -193,20 +193,6 @@ class CivitaiClient:
|
||||
else:
|
||||
logger.error(f"Download failed for {url} with status {response.status}")
|
||||
return False, f"Download failed with status {response.status}"
|
||||
|
||||
# Get filename from content-disposition header (only on first attempt)
|
||||
if retry_count == 0:
|
||||
content_disposition = response.headers.get('Content-Disposition')
|
||||
parsed_filename = self._parse_content_disposition(content_disposition)
|
||||
if parsed_filename:
|
||||
filename = parsed_filename
|
||||
# Update paths with correct filename
|
||||
save_path = os.path.join(save_dir, filename)
|
||||
new_part_path = save_path + '.part'
|
||||
# Rename existing part file if filename changed
|
||||
if part_path != new_part_path and os.path.exists(part_path):
|
||||
os.rename(part_path, new_part_path)
|
||||
part_path = new_part_path
|
||||
|
||||
# Get total file size for progress calculation (if not set from Content-Range)
|
||||
if total_size == 0:
|
||||
|
||||
@@ -389,13 +389,39 @@ class DownloadManager:
|
||||
return formatted_path
|
||||
|
||||
async def _execute_download(self, download_url: str, save_dir: str,
|
||||
metadata, version_info: Dict,
|
||||
relative_path: str, progress_callback=None,
|
||||
model_type: str = "lora", download_id: str = None) -> Dict:
|
||||
metadata, version_info: Dict,
|
||||
relative_path: str, progress_callback=None,
|
||||
model_type: str = "lora", download_id: str = None) -> Dict:
|
||||
"""Execute the actual download process including preview images and model files"""
|
||||
try:
|
||||
civitai_client = await self._get_civitai_client()
|
||||
save_path = metadata.file_path
|
||||
|
||||
# Extract original filename details
|
||||
original_filename = os.path.basename(metadata.file_path)
|
||||
base_name, extension = os.path.splitext(original_filename)
|
||||
|
||||
# Check for filename conflicts and generate unique filename if needed
|
||||
# Use the hash from metadata for conflict resolution
|
||||
def hash_provider():
|
||||
return metadata.sha256
|
||||
|
||||
unique_filename = metadata.generate_unique_filename(
|
||||
save_dir,
|
||||
base_name,
|
||||
extension,
|
||||
hash_provider=hash_provider
|
||||
)
|
||||
|
||||
# Update paths if filename changed
|
||||
if unique_filename != original_filename:
|
||||
logger.info(f"Filename conflict detected. Changing '{original_filename}' to '{unique_filename}'")
|
||||
save_path = os.path.join(save_dir, unique_filename)
|
||||
# Update metadata with new file path and name
|
||||
metadata.file_path = save_path.replace(os.sep, '/')
|
||||
metadata.file_name = os.path.splitext(unique_filename)[0]
|
||||
else:
|
||||
save_path = metadata.file_path
|
||||
|
||||
part_path = save_path + '.part'
|
||||
metadata_path = os.path.splitext(save_path)[0] + '.metadata.json'
|
||||
|
||||
@@ -491,7 +517,7 @@ class DownloadManager:
|
||||
metadata.update_file_info(save_path)
|
||||
|
||||
# 5. Final metadata update
|
||||
await MetadataManager.save_metadata(save_path, metadata, True)
|
||||
await MetadataManager.save_metadata(save_path, metadata)
|
||||
|
||||
# 6. Update cache based on model type
|
||||
if model_type == "checkpoint":
|
||||
|
||||
@@ -34,12 +34,11 @@ class EmbeddingService(BaseModelService):
|
||||
"file_size": embedding_data.get("size", 0),
|
||||
"modified": embedding_data.get("modified", ""),
|
||||
"tags": embedding_data.get("tags", []),
|
||||
"modelDescription": embedding_data.get("modelDescription", ""),
|
||||
"from_civitai": embedding_data.get("from_civitai", True),
|
||||
"notes": embedding_data.get("notes", ""),
|
||||
"model_type": embedding_data.get("model_type", "embedding"),
|
||||
"favorite": embedding_data.get("favorite", False),
|
||||
"civitai": ModelRouteUtils.filter_civitai_data(embedding_data.get("civitai", {}))
|
||||
"civitai": ModelRouteUtils.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
|
||||
}
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict:
|
||||
|
||||
@@ -34,12 +34,11 @@ class LoraService(BaseModelService):
|
||||
"file_size": lora_data.get("size", 0),
|
||||
"modified": lora_data.get("modified", ""),
|
||||
"tags": lora_data.get("tags", []),
|
||||
"modelDescription": lora_data.get("modelDescription", ""),
|
||||
"from_civitai": lora_data.get("from_civitai", True),
|
||||
"usage_tips": lora_data.get("usage_tips", ""),
|
||||
"notes": lora_data.get("notes", ""),
|
||||
"favorite": lora_data.get("favorite", False),
|
||||
"civitai": ModelRouteUtils.filter_civitai_data(lora_data.get("civitai", {}))
|
||||
"civitai": ModelRouteUtils.filter_civitai_data(lora_data.get("civitai", {}), minimal=True)
|
||||
}
|
||||
|
||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import List, Dict, Optional, Type, Set
|
||||
|
||||
from ..utils.models import BaseModelMetadata
|
||||
from ..config import config
|
||||
from ..utils.file_utils import find_preview_file
|
||||
from ..utils.file_utils import find_preview_file, get_preview_extension
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from .model_cache import ModelCache
|
||||
from .model_hash_index import ModelHashIndex
|
||||
@@ -585,6 +585,7 @@ class ModelScanner:
|
||||
if entry.is_file(follow_symlinks=True) and any(entry.name.endswith(ext) for ext in self.file_extensions):
|
||||
file_path = entry.path.replace(os.sep, "/")
|
||||
result = await self._process_model_file(file_path, original_root)
|
||||
# Only add to models if result is not None (skip corrupted metadata)
|
||||
if result:
|
||||
models.append(result)
|
||||
await asyncio.sleep(0)
|
||||
@@ -624,7 +625,12 @@ class ModelScanner:
|
||||
|
||||
async def _process_model_file(self, file_path: str, root_path: str) -> Dict:
|
||||
"""Process a single model file and return its metadata"""
|
||||
metadata = await MetadataManager.load_metadata(file_path, self.model_class)
|
||||
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.model_class)
|
||||
|
||||
if should_skip:
|
||||
# Metadata file exists but cannot be parsed - skip this model
|
||||
logger.warning(f"Skipping model {file_path} due to corrupted metadata file")
|
||||
return None
|
||||
|
||||
if metadata is None:
|
||||
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
|
||||
@@ -640,7 +646,7 @@ class ModelScanner:
|
||||
|
||||
metadata = self.model_class.from_civitai_info(version_info, file_info, file_path)
|
||||
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
|
||||
await MetadataManager.save_metadata(file_path, metadata, True)
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
logger.debug(f"Created metadata from .civitai.info for {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
|
||||
@@ -667,7 +673,7 @@ class ModelScanner:
|
||||
metadata.modelDescription = version_info['model']['description']
|
||||
|
||||
# Save the updated metadata
|
||||
await MetadataManager.save_metadata(file_path, metadata, True)
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
logger.debug(f"Updated metadata with civitai info for {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error restoring civitai data from .civitai.info for {file_path}: {e}")
|
||||
@@ -747,7 +753,7 @@ class ModelScanner:
|
||||
|
||||
model_data['civitai']['creator'] = model_metadata['creator']
|
||||
|
||||
await MetadataManager.save_metadata(file_path, model_data, True)
|
||||
await MetadataManager.save_metadata(file_path, model_data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update metadata from Civitai for {file_path}: {e}")
|
||||
|
||||
@@ -786,8 +792,16 @@ class ModelScanner:
|
||||
logger.error(f"Error adding model to cache: {e}")
|
||||
return False
|
||||
|
||||
async def move_model(self, source_path: str, target_path: str) -> bool:
|
||||
"""Move a model and its associated files to a new location"""
|
||||
async def move_model(self, source_path: str, target_path: str) -> Optional[str]:
|
||||
"""Move a model and its associated files to a new location
|
||||
|
||||
Args:
|
||||
source_path: Original file path
|
||||
target_path: Target directory path
|
||||
|
||||
Returns:
|
||||
Optional[str]: New file path if successful, None if failed
|
||||
"""
|
||||
try:
|
||||
source_path = source_path.replace(os.sep, '/')
|
||||
target_path = target_path.replace(os.sep, '/')
|
||||
@@ -796,14 +810,28 @@ class ModelScanner:
|
||||
|
||||
if not file_ext or file_ext.lower() not in self.file_extensions:
|
||||
logger.error(f"Invalid file extension for model: {file_ext}")
|
||||
return False
|
||||
return None
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(source_path))[0]
|
||||
source_dir = os.path.dirname(source_path)
|
||||
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
|
||||
target_file = os.path.join(target_path, f"{base_name}{file_ext}").replace(os.sep, '/')
|
||||
def get_source_hash():
|
||||
return self.get_hash_by_path(source_path)
|
||||
|
||||
# Check for filename conflicts and auto-rename if necessary
|
||||
from ..utils.models import BaseModelMetadata
|
||||
final_filename = BaseModelMetadata.generate_unique_filename(
|
||||
target_path, base_name, file_ext, get_source_hash
|
||||
)
|
||||
|
||||
target_file = os.path.join(target_path, final_filename).replace(os.sep, '/')
|
||||
final_base_name = os.path.splitext(final_filename)[0]
|
||||
|
||||
# Log if filename was changed due to conflict
|
||||
if final_filename != f"{base_name}{file_ext}":
|
||||
logger.info(f"Renamed {base_name}{file_ext} to {final_filename} to avoid filename conflict")
|
||||
|
||||
real_source = os.path.realpath(source_path)
|
||||
real_target = os.path.realpath(target_file)
|
||||
@@ -820,12 +848,17 @@ class ModelScanner:
|
||||
for file in os.listdir(source_dir):
|
||||
if file.startswith(base_name + ".") and file != os.path.basename(source_path):
|
||||
source_file_path = os.path.join(source_dir, file)
|
||||
# Generate new filename with the same base name as the model file
|
||||
file_suffix = file[len(base_name):] # Get the part after base_name (e.g., ".metadata.json", ".preview.png")
|
||||
new_associated_filename = f"{final_base_name}{file_suffix}"
|
||||
target_associated_path = os.path.join(target_path, new_associated_filename)
|
||||
|
||||
# Store metadata file path for special handling
|
||||
if file == f"{base_name}.metadata.json":
|
||||
source_metadata = source_file_path
|
||||
moved_metadata_path = os.path.join(target_path, file)
|
||||
moved_metadata_path = target_associated_path
|
||||
else:
|
||||
files_to_move.append((source_file_path, os.path.join(target_path, file)))
|
||||
files_to_move.append((source_file_path, target_associated_path))
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing files in {source_dir}: {e}")
|
||||
|
||||
@@ -847,11 +880,11 @@ class ModelScanner:
|
||||
|
||||
await self.update_single_model_cache(source_path, target_file, metadata)
|
||||
|
||||
return True
|
||||
return target_file
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving model: {e}", exc_info=True)
|
||||
return False
|
||||
return None
|
||||
|
||||
async def _update_metadata_paths(self, metadata_path: str, model_path: str) -> Dict:
|
||||
"""Update file paths in metadata file"""
|
||||
@@ -860,12 +893,15 @@ class ModelScanner:
|
||||
metadata = json.load(f)
|
||||
|
||||
metadata['file_path'] = model_path.replace(os.sep, '/')
|
||||
# Update file_name to match the new filename
|
||||
metadata['file_name'] = os.path.splitext(os.path.basename(model_path))[0]
|
||||
|
||||
if 'preview_url' in metadata and metadata['preview_url']:
|
||||
preview_dir = os.path.dirname(model_path)
|
||||
preview_name = os.path.splitext(os.path.basename(metadata['preview_url']))[0]
|
||||
preview_ext = os.path.splitext(metadata['preview_url'])[1]
|
||||
new_preview_path = os.path.join(preview_dir, f"{preview_name}{preview_ext}")
|
||||
# Update preview filename to match the new base name
|
||||
new_base_name = os.path.splitext(os.path.basename(model_path))[0]
|
||||
preview_ext = get_preview_extension(metadata['preview_url'])
|
||||
new_preview_path = os.path.join(preview_dir, f"{new_base_name}{preview_ext}")
|
||||
metadata['preview_url'] = new_preview_path.replace(os.sep, '/')
|
||||
|
||||
await MetadataManager.save_metadata(metadata_path, metadata)
|
||||
@@ -932,8 +968,16 @@ class ModelScanner:
|
||||
|
||||
def get_hash_by_path(self, file_path: str) -> Optional[str]:
|
||||
"""Get hash for a model by its file path"""
|
||||
return self._hash_index.get_hash(file_path)
|
||||
if self._cache is None or not self._cache.raw_data:
|
||||
return None
|
||||
|
||||
# Iterate through cache data to find matching file path
|
||||
for model_data in self._cache.raw_data:
|
||||
if model_data.get('file_path') == file_path:
|
||||
return model_data.get('sha256')
|
||||
|
||||
return None
|
||||
|
||||
def get_hash_by_filename(self, filename: str) -> Optional[str]:
|
||||
"""Get hash for a model by its filename without path"""
|
||||
return self._hash_index.get_hash_by_filename(filename)
|
||||
|
||||
114
py/services/server_i18n.py
Normal file
114
py/services/server_i18n.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ServerI18nManager:
|
||||
"""Server-side internationalization manager for template rendering"""
|
||||
|
||||
def __init__(self):
|
||||
self.translations = {}
|
||||
self.current_locale = 'en'
|
||||
self._load_translations()
|
||||
|
||||
def _load_translations(self):
|
||||
"""Load all translation files from the locales directory"""
|
||||
i18n_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||
'locales'
|
||||
)
|
||||
|
||||
if not os.path.exists(i18n_path):
|
||||
logger.warning(f"I18n directory not found: {i18n_path}")
|
||||
return
|
||||
|
||||
# Load all available locale files
|
||||
for filename in os.listdir(i18n_path):
|
||||
if filename.endswith('.json'):
|
||||
locale_code = filename[:-5] # Remove .json extension
|
||||
try:
|
||||
self._load_locale_file(i18n_path, filename, locale_code)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading locale file {filename}: {e}")
|
||||
|
||||
def _load_locale_file(self, path: str, filename: str, locale_code: str):
|
||||
"""Load a single locale JSON file"""
|
||||
file_path = os.path.join(path, filename)
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
translations = json.load(f)
|
||||
|
||||
self.translations[locale_code] = translations
|
||||
logger.debug(f"Loaded translations for {locale_code} from {filename}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing locale file {filename}: {e}")
|
||||
|
||||
def set_locale(self, locale: str):
|
||||
"""Set the current locale"""
|
||||
if locale in self.translations:
|
||||
self.current_locale = locale
|
||||
else:
|
||||
logger.warning(f"Locale {locale} not found, using 'en'")
|
||||
self.current_locale = 'en'
|
||||
|
||||
def get_translation(self, key: str, params: Dict[str, Any] = None, **kwargs) -> str:
|
||||
"""Get translation for a key with optional parameters (supports both dict and keyword args)"""
|
||||
# Merge kwargs into params for convenience
|
||||
if params is None:
|
||||
params = {}
|
||||
if kwargs:
|
||||
params = {**params, **kwargs}
|
||||
|
||||
if self.current_locale not in self.translations:
|
||||
return key
|
||||
|
||||
# Navigate through nested object using dot notation
|
||||
keys = key.split('.')
|
||||
value = self.translations[self.current_locale]
|
||||
|
||||
for k in keys:
|
||||
if isinstance(value, dict) and k in value:
|
||||
value = value[k]
|
||||
else:
|
||||
# Fallback to English if current locale doesn't have the key
|
||||
if self.current_locale != 'en' and 'en' in self.translations:
|
||||
en_value = self.translations['en']
|
||||
for k in keys:
|
||||
if isinstance(en_value, dict) and k in en_value:
|
||||
en_value = en_value[k]
|
||||
else:
|
||||
return key
|
||||
value = en_value
|
||||
else:
|
||||
return key
|
||||
break
|
||||
|
||||
if not isinstance(value, str):
|
||||
return key
|
||||
|
||||
# Replace parameters if provided
|
||||
if params:
|
||||
for param_key, param_value in params.items():
|
||||
placeholder = f"{{{param_key}}}"
|
||||
double_placeholder = f"{{{{{param_key}}}}}"
|
||||
value = value.replace(placeholder, str(param_value))
|
||||
value = value.replace(double_placeholder, str(param_value))
|
||||
|
||||
return value
|
||||
|
||||
def get_available_locales(self) -> list:
|
||||
"""Get list of available locales"""
|
||||
return list(self.translations.keys())
|
||||
|
||||
def create_template_filter(self):
|
||||
"""Create a Jinja2 filter function for templates"""
|
||||
def t_filter(key: str, **params) -> str:
|
||||
return self.get_translation(key, params)
|
||||
return t_filter
|
||||
|
||||
# Create global instance
|
||||
server_i18n = ServerI18nManager()
|
||||
@@ -80,7 +80,8 @@ class SettingsManager:
|
||||
"""Return default settings"""
|
||||
return {
|
||||
"civitai_api_key": "",
|
||||
"show_only_sfw": False
|
||||
"show_only_sfw": False,
|
||||
"language": "en" # 添加默认语言设置
|
||||
}
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
|
||||
@@ -68,6 +68,7 @@ class DownloadManager:
|
||||
optimize = data.get('optimize', True)
|
||||
model_types = data.get('model_types', ['lora', 'checkpoint'])
|
||||
delay = float(data.get('delay', 0.2)) # Default to 0.2 seconds
|
||||
delay = 0 # Temporary: Disable delay to speed up downloads
|
||||
|
||||
if not output_dir:
|
||||
return web.json_response({
|
||||
|
||||
@@ -27,39 +27,58 @@ def find_preview_file(base_name: str, dir_path: str) -> str:
|
||||
full_pattern = os.path.join(dir_path, f"{base_name}{ext}")
|
||||
if os.path.exists(full_pattern):
|
||||
# Check if this is an image and not already webp
|
||||
if ext.lower().endswith(('.jpg', '.jpeg', '.png')) and not ext.lower().endswith('.webp'):
|
||||
try:
|
||||
# Optimize the image to webp format
|
||||
webp_path = os.path.join(dir_path, f"{base_name}.webp")
|
||||
# TODO: disable the optimization for now, maybe add a config option later
|
||||
# if ext.lower().endswith(('.jpg', '.jpeg', '.png')) and not ext.lower().endswith('.webp'):
|
||||
# try:
|
||||
# # Optimize the image to webp format
|
||||
# webp_path = os.path.join(dir_path, f"{base_name}.webp")
|
||||
|
||||
# Use ExifUtils to optimize the image
|
||||
with open(full_pattern, 'rb') as f:
|
||||
image_data = f.read()
|
||||
# # Use ExifUtils to optimize the image
|
||||
# with open(full_pattern, 'rb') as f:
|
||||
# image_data = f.read()
|
||||
|
||||
optimized_data, _ = ExifUtils.optimize_image(
|
||||
image_data=image_data,
|
||||
target_width=CARD_PREVIEW_WIDTH,
|
||||
format='webp',
|
||||
quality=85,
|
||||
preserve_metadata=False
|
||||
)
|
||||
# optimized_data, _ = ExifUtils.optimize_image(
|
||||
# image_data=image_data,
|
||||
# target_width=CARD_PREVIEW_WIDTH,
|
||||
# format='webp',
|
||||
# quality=85,
|
||||
# preserve_metadata=False
|
||||
# )
|
||||
|
||||
# Save the optimized webp file
|
||||
with open(webp_path, 'wb') as f:
|
||||
f.write(optimized_data)
|
||||
# # Save the optimized webp file
|
||||
# with open(webp_path, 'wb') as f:
|
||||
# f.write(optimized_data)
|
||||
|
||||
logger.debug(f"Optimized preview image from {full_pattern} to {webp_path}")
|
||||
return webp_path.replace(os.sep, "/")
|
||||
except Exception as e:
|
||||
logger.error(f"Error optimizing preview image {full_pattern}: {e}")
|
||||
# Fall back to original file if optimization fails
|
||||
return full_pattern.replace(os.sep, "/")
|
||||
# logger.debug(f"Optimized preview image from {full_pattern} to {webp_path}")
|
||||
# return webp_path.replace(os.sep, "/")
|
||||
# except Exception as e:
|
||||
# logger.error(f"Error optimizing preview image {full_pattern}: {e}")
|
||||
# # Fall back to original file if optimization fails
|
||||
# return full_pattern.replace(os.sep, "/")
|
||||
|
||||
# Return the original path for webp images or non-image files
|
||||
return full_pattern.replace(os.sep, "/")
|
||||
|
||||
return ""
|
||||
|
||||
def get_preview_extension(preview_path: str) -> str:
|
||||
"""Get the complete preview extension from a preview file path
|
||||
|
||||
Args:
|
||||
preview_path: Path to the preview file
|
||||
|
||||
Returns:
|
||||
str: The complete extension (e.g., '.preview.png', '.png', '.webp')
|
||||
"""
|
||||
preview_path_lower = preview_path.lower()
|
||||
|
||||
# Check for compound extensions first (longer matches first)
|
||||
for ext in sorted(PREVIEW_EXTENSIONS, key=len, reverse=True):
|
||||
if preview_path_lower.endswith(ext.lower()):
|
||||
return ext
|
||||
|
||||
return os.path.splitext(preview_path)[1]
|
||||
|
||||
def normalize_path(path: str) -> str:
|
||||
"""Normalize file path to use forward slashes"""
|
||||
return path.replace(os.sep, "/") if path else path
|
||||
@@ -1,7 +1,6 @@
|
||||
from datetime import datetime
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import logging
|
||||
from typing import Dict, Optional, Type, Union
|
||||
|
||||
@@ -17,7 +16,7 @@ class MetadataManager:
|
||||
|
||||
This class is responsible for:
|
||||
1. Loading metadata safely with fallback mechanisms
|
||||
2. Saving metadata with atomic operations and backups
|
||||
2. Saving metadata with atomic operations
|
||||
3. Creating default metadata for models
|
||||
4. Handling unknown fields gracefully
|
||||
"""
|
||||
@@ -25,81 +24,44 @@ class MetadataManager:
|
||||
@staticmethod
|
||||
async def load_metadata(file_path: str, model_class: Type[BaseModelMetadata] = LoraMetadata) -> Optional[BaseModelMetadata]:
|
||||
"""
|
||||
Load metadata with robust error handling and data preservation.
|
||||
Load metadata safely.
|
||||
|
||||
Args:
|
||||
file_path: Path to the model file
|
||||
model_class: Class to instantiate (LoraMetadata, CheckpointMetadata, etc.)
|
||||
|
||||
Returns:
|
||||
BaseModelMetadata instance or None if file doesn't exist
|
||||
tuple: (metadata, should_skip)
|
||||
- metadata: BaseModelMetadata instance or None
|
||||
- should_skip: True if corrupted metadata file exists and model should be skipped
|
||||
"""
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
backup_path = f"{metadata_path}.bak"
|
||||
|
||||
# Try loading the main metadata file
|
||||
if os.path.exists(metadata_path):
|
||||
try:
|
||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Create model instance
|
||||
metadata = model_class.from_dict(data)
|
||||
|
||||
# Normalize paths
|
||||
await MetadataManager._normalize_metadata_paths(metadata, file_path)
|
||||
|
||||
return metadata
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# JSON parsing error - try to restore from backup
|
||||
logger.warning(f"Invalid JSON in metadata file: {metadata_path}")
|
||||
return await MetadataManager._restore_from_backup(backup_path, file_path, model_class)
|
||||
|
||||
except Exception as e:
|
||||
# Other errors might be due to unknown fields or schema changes
|
||||
logger.error(f"Error loading metadata from {metadata_path}: {str(e)}")
|
||||
return await MetadataManager._restore_from_backup(backup_path, file_path, model_class)
|
||||
# Check if metadata file exists
|
||||
if not os.path.exists(metadata_path):
|
||||
return None, False
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _restore_from_backup(backup_path: str, file_path: str, model_class: Type[BaseModelMetadata]) -> Optional[BaseModelMetadata]:
|
||||
"""
|
||||
Try to restore metadata from backup file
|
||||
|
||||
Args:
|
||||
backup_path: Path to backup file
|
||||
file_path: Path to the original model file
|
||||
model_class: Class to instantiate
|
||||
try:
|
||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
Returns:
|
||||
BaseModelMetadata instance or None if restoration fails
|
||||
"""
|
||||
if os.path.exists(backup_path):
|
||||
try:
|
||||
logger.info(f"Attempting to restore metadata from backup: {backup_path}")
|
||||
with open(backup_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
# Create model instance
|
||||
metadata = model_class.from_dict(data)
|
||||
|
||||
# Process data similarly to normal loading
|
||||
metadata = model_class.from_dict(data)
|
||||
await MetadataManager._normalize_metadata_paths(metadata, file_path)
|
||||
return metadata
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to restore from backup: {str(e)}")
|
||||
|
||||
return None
|
||||
# Normalize paths
|
||||
await MetadataManager._normalize_metadata_paths(metadata, file_path)
|
||||
|
||||
return metadata, False
|
||||
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
error_type = "Invalid JSON" if isinstance(e, json.JSONDecodeError) else "Parse error"
|
||||
logger.error(f"{error_type} in metadata file: {metadata_path}. Error: {str(e)}. Skipping model to preserve existing data.")
|
||||
return None, True # should_skip = True
|
||||
|
||||
@staticmethod
|
||||
async def save_metadata(path: str, metadata: Union[BaseModelMetadata, Dict], create_backup: bool = False) -> bool:
|
||||
async def save_metadata(path: str, metadata: Union[BaseModelMetadata, Dict]) -> bool:
|
||||
"""
|
||||
Save metadata with atomic write operations and backup creation.
|
||||
Save metadata with atomic write operations.
|
||||
|
||||
Args:
|
||||
path: Path to the model file or directly to the metadata file
|
||||
metadata: Metadata to save (either BaseModelMetadata object or dict)
|
||||
create_backup: Whether to create a new backup of existing file if a backup doesn't already exist
|
||||
|
||||
Returns:
|
||||
bool: Success or failure
|
||||
@@ -112,19 +74,8 @@ class MetadataManager:
|
||||
file_path = path
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
temp_path = f"{metadata_path}.tmp"
|
||||
backup_path = f"{metadata_path}.bak"
|
||||
|
||||
try:
|
||||
# Create backup if file exists and either:
|
||||
# 1. create_backup is True, OR
|
||||
# 2. backup file doesn't already exist
|
||||
if os.path.exists(metadata_path) and (create_backup or not os.path.exists(backup_path)):
|
||||
try:
|
||||
shutil.copy2(metadata_path, backup_path)
|
||||
logger.debug(f"Created metadata backup at: {backup_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create metadata backup: {str(e)}")
|
||||
|
||||
# Convert to dict if needed
|
||||
if isinstance(metadata, BaseModelMetadata):
|
||||
metadata_dict = metadata.to_dict()
|
||||
@@ -240,7 +191,7 @@ class MetadataManager:
|
||||
# await MetadataManager._enrich_metadata(metadata, real_path)
|
||||
|
||||
# Save the created metadata
|
||||
await MetadataManager.save_metadata(file_path, metadata, create_backup=False)
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
|
||||
return metadata
|
||||
|
||||
@@ -310,4 +261,4 @@ class MetadataManager:
|
||||
|
||||
# If path attributes were changed, save the metadata back to disk
|
||||
if need_update:
|
||||
await MetadataManager.save_metadata(file_path, metadata, create_backup=False)
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
|
||||
@@ -83,6 +83,50 @@ class BaseModelMetadata:
|
||||
self.size = os.path.getsize(file_path)
|
||||
self.modified = os.path.getmtime(file_path)
|
||||
self.file_path = file_path.replace(os.sep, '/')
|
||||
# Update file_name when file_path changes
|
||||
self.file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_filename(target_dir: str, base_name: str, extension: str, hash_provider: callable = None) -> str:
|
||||
"""Generate a unique filename to avoid conflicts
|
||||
|
||||
Args:
|
||||
target_dir: Target directory path
|
||||
base_name: Base filename without extension
|
||||
extension: File extension including the dot
|
||||
hash_provider: A callable that returns the SHA256 hash when needed
|
||||
|
||||
Returns:
|
||||
str: Unique filename that doesn't conflict with existing files
|
||||
"""
|
||||
original_filename = f"{base_name}{extension}"
|
||||
target_path = os.path.join(target_dir, original_filename)
|
||||
|
||||
# If no conflict, return original filename
|
||||
if not os.path.exists(target_path):
|
||||
return original_filename
|
||||
|
||||
# Only compute hash when needed
|
||||
if hash_provider:
|
||||
sha256_hash = hash_provider()
|
||||
else:
|
||||
sha256_hash = "0000"
|
||||
|
||||
# Generate short hash (first 4 characters of SHA256)
|
||||
short_hash = sha256_hash[:4] if sha256_hash else "0000"
|
||||
|
||||
# Try with short hash suffix
|
||||
unique_filename = f"{base_name}-{short_hash}{extension}"
|
||||
unique_path = os.path.join(target_dir, unique_filename)
|
||||
|
||||
# If still conflicts, add incremental number
|
||||
counter = 1
|
||||
while os.path.exists(unique_path):
|
||||
unique_filename = f"{base_name}-{short_hash}-{counter}{extension}"
|
||||
unique_path = os.path.join(target_dir, unique_filename)
|
||||
counter += 1
|
||||
|
||||
return unique_filename
|
||||
|
||||
@dataclass
|
||||
class LoraMetadata(BaseModelMetadata):
|
||||
|
||||
@@ -156,7 +156,7 @@ class ModelRouteUtils:
|
||||
local_metadata['preview_nsfw_level'] = first_preview.get('nsfwLevel', 0)
|
||||
|
||||
# Save updated metadata
|
||||
await MetadataManager.save_metadata(metadata_path, local_metadata, True)
|
||||
await MetadataManager.save_metadata(metadata_path, local_metadata)
|
||||
|
||||
@staticmethod
|
||||
async def fetch_and_update_model(
|
||||
@@ -229,13 +229,13 @@ class ModelRouteUtils:
|
||||
await client.close()
|
||||
|
||||
@staticmethod
|
||||
def filter_civitai_data(data: Dict) -> Dict:
|
||||
def filter_civitai_data(data: Dict, minimal: bool = False) -> Dict:
|
||||
"""Filter relevant fields from CivitAI data"""
|
||||
if not data:
|
||||
return {}
|
||||
|
||||
fields = [
|
||||
"id", "modelId", "name", "createdAt", "updatedAt",
|
||||
|
||||
fields = ["id", "modelId", "name", "trainedWords"] if minimal else [
|
||||
"id", "modelId", "name", "createdAt", "updatedAt",
|
||||
"publishedAt", "trainedWords", "baseModel", "description",
|
||||
"model", "images", "customImages", "creator"
|
||||
]
|
||||
@@ -982,6 +982,7 @@ class ModelRouteUtils:
|
||||
# Rename all files
|
||||
renamed_files = []
|
||||
new_metadata_path = None
|
||||
new_preview = None
|
||||
|
||||
for old_path, pattern in existing_files:
|
||||
# Get the file extension like .safetensors or .metadata.json
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "0.8.30"
|
||||
version = "0.9.0"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -339,6 +339,11 @@ class StandaloneLoraManager(LoraManager):
|
||||
logger.warning(f"Failed to add static route on initialization for {target_path}: {e}")
|
||||
continue
|
||||
|
||||
# Add static route for locales JSON files
|
||||
if os.path.exists(config.i18n_path):
|
||||
app.router.add_static('/locales', config.i18n_path)
|
||||
logger.info(f"Added static route for locales: /locales -> {config.i18n_path}")
|
||||
|
||||
# Add static route for plugin assets
|
||||
app.router.add_static('/loras_static', config.static_path)
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ html, body {
|
||||
|
||||
/* Composed Colors */
|
||||
--lora-accent: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
||||
--lora-surface: oklch(100% 0 0 / 0.98);
|
||||
--lora-surface: oklch(97% 0 0 / 0.95);
|
||||
--lora-border: oklch(90% 0.02 256 / 0.15);
|
||||
--lora-text: oklch(95% 0.02 256);
|
||||
--lora-error: oklch(75% 0.32 29);
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
}
|
||||
|
||||
/* Responsive adjustments for 1440p screens (2K) */
|
||||
@media (min-width: 2000px) {
|
||||
@media (min-width: 2150px) {
|
||||
.card-grid {
|
||||
max-width: 1800px; /* Increased for 2K screens */
|
||||
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
||||
@@ -525,7 +525,7 @@
|
||||
}
|
||||
|
||||
/* For larger screens, allow more space for the cards */
|
||||
@media (min-width: 2000px) {
|
||||
@media (min-width: 2150px) {
|
||||
.card-grid.virtual-scroll {
|
||||
max-width: 1800px;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
}
|
||||
|
||||
/* Responsive container for larger screens - match container in layout.css */
|
||||
@media (min-width: 2000px) {
|
||||
@media (min-width: 2150px) {
|
||||
.duplicates-banner .banner-content {
|
||||
max-width: 1800px;
|
||||
}
|
||||
@@ -130,7 +130,7 @@
|
||||
}
|
||||
|
||||
/* Add responsive container adjustments for duplicate groups - match container in banner */
|
||||
@media (min-width: 2000px) {
|
||||
@media (min-width: 2150px) {
|
||||
.duplicate-group {
|
||||
max-width: 1800px;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
z-index: var(--z-header);
|
||||
height: 48px; /* Reduced height */
|
||||
width: 100%;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1); /* Slightly stronger shadow */
|
||||
}
|
||||
|
||||
.header-container {
|
||||
@@ -20,7 +20,7 @@
|
||||
}
|
||||
|
||||
/* Responsive header container for larger screens */
|
||||
@media (min-width: 2000px) {
|
||||
@media (min-width: 2150px) {
|
||||
.header-container {
|
||||
max-width: 1800px;
|
||||
}
|
||||
|
||||
@@ -40,10 +40,10 @@
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 8px;
|
||||
position: absolute;
|
||||
z-index: 9999; /* 确保在卡片上方显示 */
|
||||
left: 120%; /* 将tooltip显示在图标右侧 */
|
||||
top: 50%; /* 垂直居中 */
|
||||
transform: translateY(-50%); /* 垂直居中 */
|
||||
z-index: 9999; /* Ensure tooltip appears above cards */
|
||||
left: 120%; /* Position tooltip to the right of the icon */
|
||||
top: 50%; /* Vertically center */
|
||||
transform: translateY(-15%); /* Vertically center */
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
|
||||
@@ -55,12 +55,12 @@
|
||||
.tooltip .tooltiptext::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%; /* 箭头垂直居中 */
|
||||
right: 100%; /* 箭头在左侧 */
|
||||
top: 50%; /* Vertically center arrow */
|
||||
right: 100%; /* Arrow on the left side */
|
||||
margin-top: -5px;
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: transparent var(--lora-border) transparent transparent; /* 箭头指向左侧 */
|
||||
border-color: transparent var(--lora-border) transparent transparent; /* Arrow points left */
|
||||
}
|
||||
|
||||
.tooltip:hover .tooltiptext {
|
||||
|
||||
@@ -445,69 +445,6 @@
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Switch styles */
|
||||
.search-option-switch {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 46px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: .4s;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
input:focus + .slider {
|
||||
box-shadow: 0 0 1px var(--lora-accent);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(22px);
|
||||
}
|
||||
|
||||
.slider.round {
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.slider.round:before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.search-options-panel,
|
||||
|
||||
554
static/css/components/sidebar.css
Normal file
554
static/css/components/sidebar.css
Normal file
@@ -0,0 +1,554 @@
|
||||
.folder-sidebar {
|
||||
position: fixed;
|
||||
top: 68px; /* Below header */
|
||||
left: 0px;
|
||||
width: 230px;
|
||||
height: calc(100vh - 88px);
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
flex-shrink: 0;
|
||||
z-index: var(--z-overlay);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(8px);
|
||||
/* Default state: hidden off-screen */
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Visible state */
|
||||
.folder-sidebar.visible {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* Auto-hide states */
|
||||
.folder-sidebar.auto-hide {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.folder-sidebar.auto-hide.hover-active {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.folder-sidebar.collapsed {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Hover detection area for auto-hide */
|
||||
.sidebar-hover-area {
|
||||
position: fixed;
|
||||
top: 68px;
|
||||
left: 0;
|
||||
width: 20px;
|
||||
height: calc(100vh - 88px);
|
||||
z-index: calc(var(--z-overlay) - 1);
|
||||
background: transparent;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.sidebar-hover-area.disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
font-size: 0.9em;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-header:hover {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.sidebar-header.root-selected {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.sidebar-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidebar-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sidebar-action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s ease;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar-action-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.sidebar-action-btn.active {
|
||||
opacity: 1;
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.sidebar-action-btn.disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Remove old close button styles */
|
||||
.sidebar-toggle-close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-tree-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* Sidebar Tree Node Styles */
|
||||
.sidebar-tree-node {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sidebar-tree-node-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.85em;
|
||||
border-left: 3px solid transparent;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.sidebar-tree-node-content:hover {
|
||||
background: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.sidebar-tree-node-content.selected {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
color: var(--lora-accent);
|
||||
border-left-color: var(--lora-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-tree-expand-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.sidebar-tree-expand-icon.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sidebar-tree-expand-icon i {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.sidebar-tree-folder-icon {
|
||||
margin-right: 8px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sidebar-tree-node-content.selected .sidebar-tree-folder-icon {
|
||||
color: var(--lora-accent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-tree-node-content:hover .sidebar-tree-folder-icon {
|
||||
color: var(--text-color);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.sidebar-tree-folder-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-tree-children {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
transition: max-height 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-tree-children.expanded {
|
||||
max-height: 1000px;
|
||||
}
|
||||
|
||||
.sidebar-tree-children .sidebar-tree-node-content {
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.sidebar-tree-children .sidebar-tree-children .sidebar-tree-node-content {
|
||||
padding-left: 48px;
|
||||
}
|
||||
|
||||
.sidebar-tree-children .sidebar-tree-children .sidebar-tree-children .sidebar-tree-node-content {
|
||||
padding-left: 64px;
|
||||
}
|
||||
|
||||
/* Enhanced Sidebar Breadcrumb Styles */
|
||||
.sidebar-breadcrumb-container {
|
||||
margin-top: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.sidebar-breadcrumb-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
font-size: 0.85em;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.sidebar-breadcrumb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--text-muted);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-breadcrumb-item:hover {
|
||||
background: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.sidebar-breadcrumb-item.active {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
color: var(--lora-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-breadcrumb-separator {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* New Breadcrumb Dropdown Styles */
|
||||
.breadcrumb-dropdown {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.breadcrumb-dropdown-indicator {
|
||||
margin-left: 6px;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: none;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.sidebar-breadcrumb-item:hover .breadcrumb-dropdown-indicator {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.sidebar-breadcrumb-item.placeholder {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sidebar-breadcrumb-item.placeholder:hover {
|
||||
background: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.breadcrumb-dropdown.open .breadcrumb-dropdown-indicator {
|
||||
transform: rotate(180deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.breadcrumb-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
box-shadow: 0 3px 8px rgba(0,0,0,0.15);
|
||||
z-index: calc(var(--z-overlay) + 20);
|
||||
overflow-y: auto;
|
||||
max-height: 450px;
|
||||
display: none;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.breadcrumb-dropdown.open .breadcrumb-dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.breadcrumb-dropdown-item {
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.breadcrumb-dropdown-item:hover {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.breadcrumb-dropdown-item.active {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
color: var(--lora-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Folder List Mode Styles */
|
||||
.sidebar-folder-item {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sidebar-node-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.85em;
|
||||
border-left: 3px solid transparent;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.sidebar-node-content:hover {
|
||||
background: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.sidebar-folder-item.selected .sidebar-node-content {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
color: var(--lora-accent);
|
||||
border-left-color: var(--lora-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-folder-icon {
|
||||
margin-right: 8px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-folder-item.selected .sidebar-folder-icon {
|
||||
color: var(--lora-accent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-node-content:hover .sidebar-folder-icon {
|
||||
color: var(--text-color);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.sidebar-folder-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (min-width: 2150px) {
|
||||
.folder-sidebar {
|
||||
width: 280px;
|
||||
left: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 3000px) {
|
||||
.folder-sidebar {
|
||||
width: 320px;
|
||||
left: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.folder-sidebar {
|
||||
width: 260px;
|
||||
left: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.sidebar-tree-placeholder {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sidebar-tree-placeholder i {
|
||||
font-size: 2em;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Smooth transitions for tree nodes */
|
||||
.sidebar-tree-node {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-tree-children {
|
||||
transition: max-height 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.sidebar-tree-expand-icon {
|
||||
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Visual separator for nested levels */
|
||||
.sidebar-tree-children .sidebar-tree-node-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-tree-children .sidebar-tree-node-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--border-color);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
.folder-sidebar {
|
||||
top: 68px;
|
||||
left: 0px;
|
||||
width: calc(100vw - 32px);
|
||||
max-width: 320px;
|
||||
height: calc(100vh - 88px);
|
||||
z-index: calc(var(--z-overlay) + 10);
|
||||
}
|
||||
|
||||
.folder-sidebar.collapsed {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
/* Mobile overlay */
|
||||
.folder-sidebar:not(.collapsed)::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: -1;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.folder-sidebar {
|
||||
width: calc(100vw - 32px);
|
||||
max-width: 280px;
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
.sidebar-breadcrumb-nav {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.sidebar-breadcrumb-item {
|
||||
padding: 3px 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.sidebar-tree-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar-tree-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar-tree-container::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sidebar-tree-container::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,14 +9,24 @@
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 20px auto;
|
||||
margin: 0 auto;
|
||||
padding: 0 15px;
|
||||
position: relative;
|
||||
z-index: var(--z-base);
|
||||
}
|
||||
|
||||
/* Sticky controls container */
|
||||
.controls {
|
||||
position: sticky;
|
||||
top: -54px;
|
||||
z-index: calc(var(--z-header) - 1);
|
||||
background: var(--bg-color);
|
||||
padding: var(--space-2) 0;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* Responsive container for larger screens */
|
||||
@media (min-width: 2000px) {
|
||||
@media (min-width: 2150px) {
|
||||
.container {
|
||||
max-width: 1800px;
|
||||
}
|
||||
@@ -28,13 +38,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -225,63 +228,6 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.folder-tags-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-bottom: 8px; /* Add margin to ensure space for the button */
|
||||
}
|
||||
|
||||
.folder-tags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 2px 0;
|
||||
flex-wrap: wrap;
|
||||
transition: max-height 0.3s ease, opacity 0.2s ease;
|
||||
max-height: 150px; /* Limit height to prevent overflow */
|
||||
opacity: 1;
|
||||
overflow-y: auto; /* Enable vertical scrolling */
|
||||
margin-bottom: 5px; /* Add margin below the tags */
|
||||
}
|
||||
|
||||
.folder-tags.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toggle-folders-container {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Toggle Folders Button */
|
||||
.toggle-folders-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.toggle-folders-btn:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.toggle-folders-btn i {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* Icon-only button style */
|
||||
.icon-only {
|
||||
min-width: unset !important;
|
||||
@@ -290,55 +236,6 @@
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
/* Rotate icon when folders are collapsed */
|
||||
.folder-tags.collapsed ~ .actions .toggle-folders-btn i {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Add custom scrollbar for better visibility */
|
||||
.folder-tags::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.folder-tags::-webkit-scrollbar-track {
|
||||
background: var(--card-bg);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.folder-tags::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.folder-tags::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--lora-accent);
|
||||
}
|
||||
|
||||
.tag {
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
margin: 2px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
display: inline-block;
|
||||
line-height: 1.2;
|
||||
font-size: 14px;
|
||||
background-color: var(--card-bg);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
border-color: var(--lora-accent);
|
||||
background-color: oklch(var(--lora-accent) / 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tag.active {
|
||||
background-color: var(--lora-accent);
|
||||
color: white;
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Back to Top Button */
|
||||
.back-to-top {
|
||||
position: fixed;
|
||||
@@ -376,10 +273,8 @@
|
||||
}
|
||||
|
||||
/* Prevent text selection in control and header areas */
|
||||
.tag,
|
||||
.control-group button,
|
||||
.control-group select,
|
||||
.toggle-folders-btn,
|
||||
.bulk-operations-panel,
|
||||
.app-header,
|
||||
.header-branding,
|
||||
@@ -387,8 +282,7 @@
|
||||
.main-nav,
|
||||
.nav-item,
|
||||
.header-actions button,
|
||||
.header-controls,
|
||||
.toggle-folders-container button {
|
||||
.header-controls {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
@@ -472,18 +366,6 @@
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.toggle-folders-container {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.folder-tags-container {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.toggle-folders-btn:hover {
|
||||
transform: none; /* Disable hover effects on mobile */
|
||||
}
|
||||
|
||||
.control-group button:hover {
|
||||
transform: none; /* Disable hover effects on mobile */
|
||||
@@ -493,10 +375,6 @@
|
||||
transform: none; /* Disable hover effects on mobile */
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
transform: none; /* Disable hover effects on mobile */
|
||||
}
|
||||
|
||||
.back-to-top {
|
||||
bottom: 60px; /* Give some extra space from bottom on mobile */
|
||||
}
|
||||
@@ -505,4 +383,9 @@
|
||||
left: auto;
|
||||
right: 0; /* Align to right on mobile */
|
||||
}
|
||||
|
||||
/* Adjust controls padding on mobile */
|
||||
.controls {
|
||||
padding: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
252
static/css/onboarding.css
Normal file
252
static/css/onboarding.css
Normal file
@@ -0,0 +1,252 @@
|
||||
/* Onboarding Tutorial Styles */
|
||||
.onboarding-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: var(--z-overlay);
|
||||
display: none;
|
||||
/* Use mask to create cutout for highlighted element */
|
||||
mask-composite: subtract;
|
||||
-webkit-mask-composite: subtract;
|
||||
}
|
||||
|
||||
.onboarding-overlay.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.onboarding-spotlight {
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
border: 3px solid var(--lora-accent);
|
||||
border-radius: var(--border-radius-base);
|
||||
z-index: calc(var(--z-overlay) + 1);
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
/* Add glow effect */
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(24, 144, 255, 0.3),
|
||||
0 0 20px rgba(24, 144, 255, 0.2),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Target element highlighting */
|
||||
.onboarding-target-highlight {
|
||||
position: relative;
|
||||
z-index: calc(var(--z-overlay) + 2) !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
/* Ensure highlighted elements are interactive */
|
||||
.onboarding-target-highlight * {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.onboarding-popup {
|
||||
position: absolute;
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: var(--space-3);
|
||||
min-width: 320px;
|
||||
max-width: 400px;
|
||||
z-index: calc(var(--z-overlay) + 3);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.onboarding-popup h3 {
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
color: var(--lora-accent);
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.onboarding-popup p {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
color: var(--text-color);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.onboarding-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.onboarding-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.onboarding-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.onboarding-btn {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.onboarding-btn:hover {
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.onboarding-btn.primary {
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.onboarding-btn.primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Language Selection Modal */
|
||||
.language-selection-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: calc(var(--z-overlay) + 10);
|
||||
}
|
||||
|
||||
.language-selection-content {
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: var(--space-3);
|
||||
min-width: 510px;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.language-selection-content h2 {
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
color: var(--lora-accent);
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.language-selection-content p {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
color: var(--text-color);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.language-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.language-option {
|
||||
padding: var(--space-2);
|
||||
border: 2px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--card-bg);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.language-option:hover {
|
||||
border-color: var(--lora-accent);
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.language-option.selected {
|
||||
border-color: var(--lora-accent);
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
}
|
||||
|
||||
.language-flag {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.language-name {
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.language-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Shortcut Key Highlighting */
|
||||
.onboarding-shortcut {
|
||||
display: inline-block;
|
||||
background: var(--shortcut-bg);
|
||||
border: 1px solid var(--shortcut-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 2px 6px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
color: var(--shortcut-text);
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* Animation for highlighting elements */
|
||||
.onboarding-highlight {
|
||||
animation: onboarding-pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes onboarding-pulse {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(24, 144, 255, 0.4),
|
||||
0 0 20px rgba(24, 144, 255, 0.3),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(24, 144, 255, 0.6),
|
||||
0 0 30px rgba(24, 144, 255, 0.4),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.onboarding-popup {
|
||||
min-width: 280px;
|
||||
max-width: calc(100vw - 40px);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.language-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.language-selection-content {
|
||||
min-width: calc(100vw - 40px);
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,10 @@
|
||||
@import 'components/filter-indicator.css';
|
||||
@import 'components/initialization.css';
|
||||
@import 'components/progress-panel.css';
|
||||
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */
|
||||
@import 'components/duplicates.css'; /* Add duplicates component */
|
||||
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
|
||||
@import 'components/statistics.css'; /* Add statistics component */
|
||||
@import 'components/sidebar.css'; /* Add sidebar component */
|
||||
|
||||
.initialization-notice {
|
||||
display: flex;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
@@ -88,6 +88,8 @@ export function getApiEndpoints(modelType) {
|
||||
duplicates: `/api/${modelType}/find-duplicates`,
|
||||
conflicts: `/api/${modelType}/find-filename-conflicts`,
|
||||
verify: `/api/${modelType}/verify-duplicates`,
|
||||
metadata: `/api/${modelType}/metadata`,
|
||||
modelDescription: `/api/${modelType}/model-description`,
|
||||
|
||||
// Model-specific endpoints (will be merged with specific configs)
|
||||
specific: {}
|
||||
@@ -104,7 +106,7 @@ export const MODEL_SPECIFIC_ENDPOINTS = {
|
||||
triggerWords: `/api/${MODEL_TYPES.LORA}/get-trigger-words`,
|
||||
previewUrl: `/api/${MODEL_TYPES.LORA}/preview-url`,
|
||||
civitaiUrl: `/api/${MODEL_TYPES.LORA}/civitai-url`,
|
||||
modelDescription: `/api/${MODEL_TYPES.LORA}/model-description`,
|
||||
metadata: `/api/${MODEL_TYPES.LORA}/metadata`,
|
||||
getTriggerWordsPost: `/api/${MODEL_TYPES.LORA}/get_trigger_words`,
|
||||
civitaiModelByVersion: `/api/${MODEL_TYPES.LORA}/civitai/model/version`,
|
||||
civitaiModelByHash: `/api/${MODEL_TYPES.LORA}/civitai/model/hash`,
|
||||
@@ -113,8 +115,10 @@ export const MODEL_SPECIFIC_ENDPOINTS = {
|
||||
info: `/api/${MODEL_TYPES.CHECKPOINT}/info`,
|
||||
checkpoints_roots: `/api/${MODEL_TYPES.CHECKPOINT}/checkpoints_roots`,
|
||||
unet_roots: `/api/${MODEL_TYPES.CHECKPOINT}/unet_roots`,
|
||||
metadata: `/api/${MODEL_TYPES.CHECKPOINT}/metadata`,
|
||||
},
|
||||
[MODEL_TYPES.EMBEDDING]: {
|
||||
metadata: `/api/${MODEL_TYPES.EMBEDDING}/metadata`,
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { showToast, updateFolderTags } from '../utils/uiHelpers.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { getStorageItem, getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
|
||||
import {
|
||||
getCompleteApiConfig,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
WS_ENDPOINTS
|
||||
} from './apiConfig.js';
|
||||
import { resetAndReload } from './modelApiFactory.js';
|
||||
import { sidebarManager } from '../components/SidebarManager.js';
|
||||
|
||||
/**
|
||||
* Abstract base class for all model API clients
|
||||
@@ -75,7 +77,7 @@ export class BaseModelApiClient {
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${this.apiConfig.config.displayName}s:`, error);
|
||||
showToast(`Failed to fetch ${this.apiConfig.config.displayName}s: ${error.message}`, 'error');
|
||||
showToast('toast.api.fetchFailed', { type: this.apiConfig.config.displayName, message: error.message }, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -103,21 +105,13 @@ export class BaseModelApiClient {
|
||||
pageState.currentPage = pageState.currentPage + 1;
|
||||
|
||||
if (updateFolders) {
|
||||
const response = await fetch(this.apiConfig.endpoints.folders);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
updateFolderTags(data.folders);
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const errorMsg = errorData && errorData.error ? errorData.error : response.statusText;
|
||||
console.error(`Error getting folders: ${errorMsg}`);
|
||||
}
|
||||
sidebarManager.refresh();
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error reloading ${this.apiConfig.config.displayName}s:`, error);
|
||||
showToast(`Failed to reload ${this.apiConfig.config.displayName}s: ${error.message}`, 'error');
|
||||
showToast('toast.api.reloadFailed', { type: this.apiConfig.config.displayName, message: error.message }, 'error');
|
||||
throw error;
|
||||
} finally {
|
||||
pageState.isLoading = false;
|
||||
@@ -145,14 +139,14 @@ export class BaseModelApiClient {
|
||||
if (state.virtualScroller) {
|
||||
state.virtualScroller.removeItemByFilePath(filePath);
|
||||
}
|
||||
showToast(`${this.apiConfig.config.displayName} deleted successfully`, 'success');
|
||||
showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success');
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(data.error || `Failed to delete ${this.apiConfig.config.singularName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error deleting ${this.apiConfig.config.singularName}:`, error);
|
||||
showToast(`Failed to delete ${this.apiConfig.config.singularName}: ${error.message}`, 'error');
|
||||
showToast('toast.api.deleteFailed', { type: this.apiConfig.config.singularName, message: error.message }, 'error');
|
||||
return false;
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
@@ -179,14 +173,14 @@ export class BaseModelApiClient {
|
||||
if (state.virtualScroller) {
|
||||
state.virtualScroller.removeItemByFilePath(filePath);
|
||||
}
|
||||
showToast(`${this.apiConfig.config.displayName} excluded successfully`, 'success');
|
||||
showToast('toast.api.excludeSuccess', { type: this.apiConfig.config.displayName }, 'success');
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(data.error || `Failed to exclude ${this.apiConfig.config.singularName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error excluding ${this.apiConfig.config.singularName}:`, error);
|
||||
showToast(`Failed to exclude ${this.apiConfig.config.singularName}: ${error.message}`, 'error');
|
||||
showToast('toast.api.excludeFailed', { type: this.apiConfig.config.singularName, message: error.message }, 'error');
|
||||
return false;
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
@@ -215,9 +209,9 @@ export class BaseModelApiClient {
|
||||
preview_url: result.new_preview_path
|
||||
});
|
||||
|
||||
showToast('File name updated successfully', 'success');
|
||||
showToast('toast.api.fileNameUpdated', {}, 'success');
|
||||
} else {
|
||||
showToast('Failed to rename file: ' + (result.error || 'Unknown error'), 'error');
|
||||
showToast('toast.api.fileRenameFailed', { error: result.error || 'Unknown error' }, 'error');
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -279,10 +273,10 @@ export class BaseModelApiClient {
|
||||
};
|
||||
|
||||
state.virtualScroller.updateSingleItem(filePath, updateData);
|
||||
showToast('Preview updated successfully', 'success');
|
||||
showToast('toast.api.previewUpdated', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error uploading preview:', error);
|
||||
showToast('Failed to upload preview image', 'error');
|
||||
showToast('toast.api.previewUploadFailed', {}, 'error');
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
@@ -329,10 +323,10 @@ export class BaseModelApiClient {
|
||||
|
||||
resetAndReload(true);
|
||||
|
||||
showToast(`${fullRebuild ? 'Full rebuild' : 'Refresh'} complete`, 'success');
|
||||
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
|
||||
} catch (error) {
|
||||
console.error('Refresh failed:', error);
|
||||
showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${this.apiConfig.config.displayName}s`, 'error');
|
||||
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: this.apiConfig.config.displayName }, 'error');
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
state.loadingManager.restoreProgressBar();
|
||||
@@ -360,14 +354,14 @@ export class BaseModelApiClient {
|
||||
state.virtualScroller.updateSingleItem(filePath, data.metadata);
|
||||
}
|
||||
|
||||
showToast('Metadata refreshed successfully', 'success');
|
||||
showToast('toast.api.metadataRefreshed', {}, 'success');
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to refresh metadata');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing metadata:', error);
|
||||
showToast(error.message, 'error');
|
||||
showToast('toast.api.metadataRefreshFailed', { message: error.message }, 'error');
|
||||
return false;
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
@@ -419,6 +413,7 @@ export class BaseModelApiClient {
|
||||
};
|
||||
});
|
||||
|
||||
// Wait for WebSocket connection to establish
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.onopen = resolve;
|
||||
ws.onerror = reject;
|
||||
@@ -434,13 +429,14 @@ export class BaseModelApiClient {
|
||||
throw new Error('Failed to fetch metadata');
|
||||
}
|
||||
|
||||
// Wait for the operation to complete via WebSocket
|
||||
await operationComplete;
|
||||
|
||||
resetAndReload(false);
|
||||
showToast('Metadata update complete', 'success');
|
||||
showToast('toast.api.metadataUpdateComplete', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error fetching metadata:', error);
|
||||
showToast('Failed to fetch metadata: ' + error.message, 'error');
|
||||
showToast('toast.api.metadataFetchFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
@@ -508,22 +504,22 @@ export class BaseModelApiClient {
|
||||
|
||||
let completionMessage;
|
||||
if (successCount === totalItems) {
|
||||
completionMessage = `Successfully refreshed all ${successCount} ${this.apiConfig.config.displayName}s`;
|
||||
showToast(completionMessage, 'success');
|
||||
completionMessage = translate('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, `Successfully refreshed all ${successCount} ${this.apiConfig.config.displayName}s`);
|
||||
showToast('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, 'success');
|
||||
} else if (successCount > 0) {
|
||||
completionMessage = `Refreshed ${successCount} of ${totalItems} ${this.apiConfig.config.displayName}s`;
|
||||
showToast(completionMessage, 'warning');
|
||||
completionMessage = translate('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, `Refreshed ${successCount} of ${totalItems} ${this.apiConfig.config.displayName}s`);
|
||||
showToast('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, 'warning');
|
||||
|
||||
if (failedItems.length > 0) {
|
||||
const failureMessage = failedItems.length <= 3
|
||||
? failedItems.map(item => `${item.fileName}: ${item.error}`).join('\n')
|
||||
: failedItems.slice(0, 3).map(item => `${item.fileName}: ${item.error}`).join('\n') +
|
||||
`\n(and ${failedItems.length - 3} more)`;
|
||||
showToast(`Failed refreshes:\n${failureMessage}`, 'warning', 6000);
|
||||
showToast('toast.api.bulkMetadataFailureDetails', { failures: failureMessage }, 'warning', 6000);
|
||||
}
|
||||
} else {
|
||||
completionMessage = `Failed to refresh metadata for any ${this.apiConfig.config.displayName}s`;
|
||||
showToast(completionMessage, 'error');
|
||||
completionMessage = translate('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, `Failed to refresh metadata for any ${this.apiConfig.config.displayName}s`);
|
||||
showToast('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, 'error');
|
||||
}
|
||||
|
||||
await progressController.complete(completionMessage);
|
||||
@@ -539,7 +535,7 @@ export class BaseModelApiClient {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in bulk metadata refresh:', error);
|
||||
showToast(`Failed to refresh metadata: ${error.message}`, 'error');
|
||||
showToast('toast.api.bulkMetadataFailed', { message: error.message }, 'error');
|
||||
await progressController.complete('Operation failed');
|
||||
throw error;
|
||||
}
|
||||
@@ -669,9 +665,10 @@ export class BaseModelApiClient {
|
||||
if (pageState.searchOptions.creator !== undefined) {
|
||||
params.append('search_creator', pageState.searchOptions.creator.toString());
|
||||
}
|
||||
params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString());
|
||||
}
|
||||
}
|
||||
|
||||
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
|
||||
|
||||
if (pageState.filters) {
|
||||
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
|
||||
@@ -714,11 +711,11 @@ export class BaseModelApiClient {
|
||||
async moveSingleModel(filePath, targetPath) {
|
||||
// Only allow move if supported
|
||||
if (!this.apiConfig.config.supportsMove) {
|
||||
showToast(`Moving ${this.apiConfig.config.displayName}s is not supported`, 'warning');
|
||||
showToast('toast.api.moveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
|
||||
return null;
|
||||
}
|
||||
if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) {
|
||||
showToast(`${this.apiConfig.config.displayName} is already in the selected folder`, 'info');
|
||||
showToast('toast.api.alreadyInFolder', { type: this.apiConfig.config.displayName }, 'info');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -743,20 +740,23 @@ export class BaseModelApiClient {
|
||||
}
|
||||
|
||||
if (result && result.message) {
|
||||
showToast(result.message, 'info');
|
||||
showToast('toast.api.moveInfo', { message: result.message }, 'info');
|
||||
} else {
|
||||
showToast(`${this.apiConfig.config.displayName} moved successfully`, 'success');
|
||||
showToast('toast.api.moveSuccess', { type: this.apiConfig.config.displayName }, 'success');
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
return result.new_file_path;
|
||||
return {
|
||||
original_file_path: result.original_file_path || filePath,
|
||||
new_file_path: result.new_file_path
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async moveBulkModels(filePaths, targetPath) {
|
||||
if (!this.apiConfig.config.supportsMove) {
|
||||
showToast(`Moving ${this.apiConfig.config.displayName}s is not supported`, 'warning');
|
||||
showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
|
||||
return [];
|
||||
}
|
||||
const movedPaths = filePaths.filter(path => {
|
||||
@@ -764,7 +764,7 @@ export class BaseModelApiClient {
|
||||
});
|
||||
|
||||
if (movedPaths.length === 0) {
|
||||
showToast(`All selected ${this.apiConfig.config.displayName}s are already in the target folder`, 'info');
|
||||
showToast('toast.api.allAlreadyInFolder', { type: this.apiConfig.config.displayName }, 'info');
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -785,33 +785,38 @@ export class BaseModelApiClient {
|
||||
throw new Error(`Failed to move ${this.apiConfig.config.displayName}s`);
|
||||
}
|
||||
|
||||
let successFilePaths = [];
|
||||
if (result.success) {
|
||||
if (result.failure_count > 0) {
|
||||
showToast(`Moved ${result.success_count} ${this.apiConfig.config.displayName}s, ${result.failure_count} failed`, 'warning');
|
||||
showToast('toast.api.bulkMovePartial', {
|
||||
successCount: result.success_count,
|
||||
type: this.apiConfig.config.displayName,
|
||||
failureCount: result.failure_count
|
||||
}, 'warning');
|
||||
console.log('Move operation results:', result.results);
|
||||
const failedFiles = result.results
|
||||
.filter(r => !r.success)
|
||||
.map(r => {
|
||||
const fileName = r.path.substring(r.path.lastIndexOf('/') + 1);
|
||||
const fileName = r.original_file_path.substring(r.original_file_path.lastIndexOf('/') + 1);
|
||||
return `${fileName}: ${r.message}`;
|
||||
});
|
||||
if (failedFiles.length > 0) {
|
||||
const failureMessage = failedFiles.length <= 3
|
||||
? failedFiles.join('\n')
|
||||
: failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`;
|
||||
showToast(`Failed moves:\n${failureMessage}`, 'warning', 6000);
|
||||
showToast('toast.api.bulkMoveFailures', { failures: failureMessage }, 'warning', 6000);
|
||||
}
|
||||
} else {
|
||||
showToast(`Successfully moved ${result.success_count} ${this.apiConfig.config.displayName}s`, 'success');
|
||||
showToast('toast.api.bulkMoveSuccess', {
|
||||
successCount: result.success_count,
|
||||
type: this.apiConfig.config.displayName
|
||||
}, 'success');
|
||||
}
|
||||
successFilePaths = result.results
|
||||
.filter(r => r.success)
|
||||
.map(r => r.path);
|
||||
|
||||
// Return the results array with original_file_path and new_file_path
|
||||
return result.results || [];
|
||||
} else {
|
||||
throw new Error(result.message || `Failed to move ${this.apiConfig.config.displayName}s`);
|
||||
}
|
||||
return successFilePaths;
|
||||
}
|
||||
|
||||
async bulkDeleteModels(filePaths) {
|
||||
@@ -936,12 +941,12 @@ export class BaseModelApiClient {
|
||||
// Wait for the operation to complete via WebSocket
|
||||
await operationComplete;
|
||||
|
||||
showToast('Successfully downloaded example images!', 'success');
|
||||
showToast('toast.api.exampleImagesDownloadSuccess', {}, 'success');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error downloading example images:', error);
|
||||
showToast(`Failed to download example images: ${error.message}`, 'error');
|
||||
showToast('toast.api.exampleImagesDownloadFailed', { message: error.message }, 'error');
|
||||
throw error;
|
||||
} finally {
|
||||
if (ws) {
|
||||
@@ -953,4 +958,48 @@ export class BaseModelApiClient {
|
||||
completionMessage: 'Example images download complete'
|
||||
});
|
||||
}
|
||||
|
||||
async fetchModelMetadata(filePath) {
|
||||
try {
|
||||
const params = new URLSearchParams({ file_path: filePath });
|
||||
const response = await fetch(`${this.apiConfig.endpoints.metadata}?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${this.apiConfig.config.singularName} metadata: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
return data.metadata;
|
||||
} else {
|
||||
throw new Error(data.error || `No metadata found for ${this.apiConfig.config.singularName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${this.apiConfig.config.singularName} metadata:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchModelDescription(filePath) {
|
||||
try {
|
||||
const params = new URLSearchParams({ file_path: filePath });
|
||||
const response = await fetch(`${this.apiConfig.endpoints.modelDescription}?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${this.apiConfig.config.singularName} description: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
return data.description;
|
||||
} else {
|
||||
throw new Error(data.error || `No description found for ${this.apiConfig.config.singularName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${this.apiConfig.config.singularName} description:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { BaseModelApiClient } from './baseModelApi.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
|
||||
/**
|
||||
* Checkpoint-specific API client
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { BaseModelApiClient } from './baseModelApi.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
|
||||
/**
|
||||
* Embedding-specific API client
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { BaseModelApiClient } from './baseModelApi.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { getSessionItem } from '../utils/storageHelpers.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,7 +89,7 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching recipes:', error);
|
||||
showToast(`Failed to fetch recipes: ${error.message}`, 'error');
|
||||
showToast('toast.recipes.fetchFailed', { message: error.message }, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error reloading ${modelType}s:`, error);
|
||||
showToast(`Failed to reload ${modelType}s: ${error.message}`, 'error');
|
||||
showToast('toast.recipes.reloadFailed', { modelType: modelType, message: error.message }, 'error');
|
||||
throw error;
|
||||
} finally {
|
||||
pageState.isLoading = false;
|
||||
@@ -179,7 +179,7 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${modelType}s:`, error);
|
||||
showToast(`Failed to load ${modelType}s: ${error.message}`, 'error');
|
||||
showToast('toast.recipes.loadFailed', { modelType: modelType, message: error.message }, 'error');
|
||||
throw error;
|
||||
} finally {
|
||||
pageState.isLoading = false;
|
||||
@@ -217,10 +217,10 @@ export async function refreshRecipes() {
|
||||
// After successful cache rebuild, reload the recipes
|
||||
await resetAndReload();
|
||||
|
||||
showToast('Refresh complete', 'success');
|
||||
showToast('toast.recipes.refreshComplete', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error refreshing recipes:', error);
|
||||
showToast(error.message || 'Failed to refresh recipes', 'error');
|
||||
showToast('toast.recipes.refreshFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
state.loadingManager.restoreProgressBar();
|
||||
@@ -285,7 +285,7 @@ export async function updateRecipeMetadata(filePath, updates) {
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
showToast(`Failed to update recipe: ${data.error}`, 'error');
|
||||
showToast('toast.recipes.updateFailed', { error: data.error }, 'error');
|
||||
throw new Error(data.error || 'Failed to update recipe');
|
||||
}
|
||||
|
||||
@@ -294,7 +294,7 @@ export async function updateRecipeMetadata(filePath, updates) {
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error updating recipe:', error);
|
||||
showToast(`Error updating recipe: ${error.message}`, 'error');
|
||||
showToast('toast.recipes.updateError', { message: error.message }, 'error');
|
||||
throw error;
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
|
||||
@@ -30,10 +30,6 @@ class CheckpointsPageManager {
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
// Initialize page-specific components
|
||||
this.pageControls.restoreFolderFilter();
|
||||
this.pageControls.initFolderTagsVisibility();
|
||||
|
||||
// Initialize context menu
|
||||
new CheckpointContextMenu();
|
||||
|
||||
|
||||
@@ -25,10 +25,10 @@ export const ModelContextMenuMixin = {
|
||||
try {
|
||||
await this.saveModelMetadata(filePath, { preview_nsfw_level: level });
|
||||
|
||||
showToast(`Content rating set to ${getNSFWLevelName(level)}`, 'success');
|
||||
showToast('toast.contextMenu.contentRatingSet', { level: getNSFWLevelName(level) }, 'success');
|
||||
this.nsfwSelector.style.display = 'none';
|
||||
} catch (error) {
|
||||
showToast(`Failed to set content rating: ${error.message}`, 'error');
|
||||
showToast('toast.contextMenu.contentRatingFailed', { message: error.message }, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -147,7 +147,7 @@ export const ModelContextMenuMixin = {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast('Model successfully re-linked to Civitai', 'success');
|
||||
showToast('toast.contextMenu.relinkSuccess', {}, 'success');
|
||||
// Reload the current view to show updated data
|
||||
await this.resetAndReload();
|
||||
} else {
|
||||
@@ -155,7 +155,7 @@ export const ModelContextMenuMixin = {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error re-linking model:', error);
|
||||
showToast(`Error: ${error.message}`, 'error');
|
||||
showToast('toast.contextMenu.relinkFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
@@ -211,10 +211,10 @@ export const ModelContextMenuMixin = {
|
||||
if (this.currentCard.querySelector('.fa-globe')) {
|
||||
this.currentCard.querySelector('.fa-globe').click();
|
||||
} else {
|
||||
showToast('Please fetch metadata from CivitAI first', 'info');
|
||||
showToast('toast.contextMenu.fetchMetadataFirst', {}, 'info');
|
||||
}
|
||||
} else {
|
||||
showToast('No CivitAI information available', 'info');
|
||||
showToast('toast.contextMenu.noCivitaiInfo', {}, 'info');
|
||||
}
|
||||
return true;
|
||||
case 'relink-civitai':
|
||||
@@ -232,7 +232,7 @@ export const ModelContextMenuMixin = {
|
||||
async downloadExampleImages() {
|
||||
const modelHash = this.currentCard.dataset.sha256;
|
||||
if (!modelHash) {
|
||||
showToast('Model hash not available', 'error');
|
||||
showToast('toast.contextMenu.missingHash', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
copyRecipeSyntax() {
|
||||
const recipeId = this.currentCard.dataset.id;
|
||||
if (!recipeId) {
|
||||
showToast('Cannot copy recipe: Missing recipe ID', 'error');
|
||||
showToast('recipes.contextMenu.copyRecipe.missingId', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy recipe syntax: ', err);
|
||||
showToast('Failed to copy recipe syntax', 'error');
|
||||
showToast('recipes.contextMenu.copyRecipe.failed', {}, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
sendRecipeToWorkflow(replaceMode) {
|
||||
const recipeId = this.currentCard.dataset.id;
|
||||
if (!recipeId) {
|
||||
showToast('Cannot send recipe: Missing recipe ID', 'error');
|
||||
showToast('recipes.contextMenu.sendRecipe.missingId', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,14 +137,14 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to send recipe to workflow: ', err);
|
||||
showToast('Failed to send recipe to workflow', 'error');
|
||||
showToast('recipes.contextMenu.sendRecipe.failed', {}, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// View all LoRAs in the recipe
|
||||
viewRecipeLoRAs(recipeId) {
|
||||
if (!recipeId) {
|
||||
showToast('Cannot view LoRAs: Missing recipe ID', 'error');
|
||||
showToast('recipes.contextMenu.viewLoras.missingId', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -171,19 +171,19 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
// Navigate to the LoRAs page
|
||||
window.location.href = '/loras';
|
||||
} else {
|
||||
showToast('No LoRAs found in this recipe', 'info');
|
||||
showToast('recipes.contextMenu.viewLoras.noLorasFound', {}, 'info');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading recipe LoRAs:', error);
|
||||
showToast('Error loading recipe LoRAs: ' + error.message, 'error');
|
||||
showToast('recipes.contextMenu.viewLoras.loadError', { message: error.message }, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Download missing LoRAs
|
||||
async downloadMissingLoRAs(recipeId) {
|
||||
if (!recipeId) {
|
||||
showToast('Cannot download LoRAs: Missing recipe ID', 'error');
|
||||
showToast('recipes.contextMenu.downloadMissing.missingId', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted);
|
||||
|
||||
if (missingLoras.length === 0) {
|
||||
showToast('No missing LoRAs to download', 'info');
|
||||
showToast('recipes.contextMenu.downloadMissing.noMissingLoras', {}, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
const validLoras = lorasWithVersionInfo.filter(lora => lora !== null);
|
||||
|
||||
if (validLoras.length === 0) {
|
||||
showToast('Failed to get information for missing LoRAs', 'error');
|
||||
showToast('recipes.contextMenu.downloadMissing.getInfoFailed', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
window.importManager.downloadMissingLoras(recipeData, recipeId);
|
||||
} catch (error) {
|
||||
console.error('Error downloading missing LoRAs:', error);
|
||||
showToast('Error preparing LoRAs for download: ' + error.message, 'error');
|
||||
showToast('recipes.contextMenu.downloadMissing.prepareError', { message: error.message }, 'error');
|
||||
} finally {
|
||||
if (state.loadingManager) {
|
||||
state.loadingManager.hide();
|
||||
|
||||
@@ -26,7 +26,7 @@ export class DuplicatesManager {
|
||||
this.duplicateGroups = data.duplicate_groups || [];
|
||||
|
||||
if (this.duplicateGroups.length === 0) {
|
||||
showToast('No duplicate recipes found', 'info');
|
||||
showToast('toast.duplicates.noDuplicatesFound', { type: 'recipes' }, 'info');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export class DuplicatesManager {
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error finding duplicates:', error);
|
||||
showToast('Failed to find duplicates: ' + error.message, 'error');
|
||||
showToast('toast.duplicates.findFailed', { message: error.message }, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -325,7 +325,7 @@ export class DuplicatesManager {
|
||||
|
||||
async deleteSelectedDuplicates() {
|
||||
if (this.selectedForDeletion.size === 0) {
|
||||
showToast('No recipes selected for deletion', 'info');
|
||||
showToast('toast.duplicates.noItemsSelected', { type: 'recipes' }, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@ export class DuplicatesManager {
|
||||
modalManager.showModal('duplicateDeleteModal');
|
||||
} catch (error) {
|
||||
console.error('Error preparing delete:', error);
|
||||
showToast('Error: ' + error.message, 'error');
|
||||
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,7 +371,7 @@ export class DuplicatesManager {
|
||||
throw new Error(data.error || 'Unknown error deleting recipes');
|
||||
}
|
||||
|
||||
showToast(`Successfully deleted ${data.total_deleted} recipes`, 'success');
|
||||
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: 'recipes' }, 'success');
|
||||
|
||||
// Exit duplicate mode if deletions were successful
|
||||
if (data.total_deleted > 0) {
|
||||
@@ -380,7 +380,7 @@ export class DuplicatesManager {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting recipes:', error);
|
||||
showToast('Failed to delete recipes: ' + error.message, 'error');
|
||||
showToast('toast.duplicates.deleteFailed', { type: 'recipes', message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { toggleTheme } from '../utils/uiHelpers.js';
|
||||
import { SearchManager } from '../managers/SearchManager.js';
|
||||
import { FilterManager } from '../managers/FilterManager.js';
|
||||
import { initPageState } from '../state/index.js';
|
||||
import { getStorageItem } from '../utils/storageHelpers.js';
|
||||
import { updateElementAttribute } from '../utils/i18nHelpers.js';
|
||||
|
||||
/**
|
||||
* Header.js - Manages the application header behavior across different pages
|
||||
@@ -47,21 +49,17 @@ export class HeaderManager {
|
||||
// Handle theme toggle
|
||||
const themeToggle = document.querySelector('.theme-toggle');
|
||||
if (themeToggle) {
|
||||
// Set initial state based on current theme
|
||||
const currentTheme = localStorage.getItem('lm_theme') || 'auto';
|
||||
const currentTheme = getStorageItem('theme') || 'auto';
|
||||
themeToggle.classList.add(`theme-${currentTheme}`);
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
|
||||
// Use i18nHelpers to update themeToggle's title
|
||||
this.updateThemeTooltip(themeToggle, currentTheme);
|
||||
|
||||
themeToggle.addEventListener('click', async () => {
|
||||
if (typeof toggleTheme === 'function') {
|
||||
const newTheme = toggleTheme();
|
||||
// Update tooltip based on next toggle action
|
||||
if (newTheme === 'light') {
|
||||
themeToggle.title = "Switch to dark theme";
|
||||
} else if (newTheme === 'dark') {
|
||||
themeToggle.title = "Switch to auto theme";
|
||||
} else {
|
||||
themeToggle.title = "Switch to light theme";
|
||||
}
|
||||
// Use i18nHelpers to update themeToggle's title
|
||||
this.updateThemeTooltip(themeToggle, newTheme);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -125,29 +123,43 @@ export class HeaderManager {
|
||||
// Hide search functionality on Statistics page
|
||||
this.updateHeaderForPage();
|
||||
}
|
||||
|
||||
|
||||
updateHeaderForPage() {
|
||||
const headerSearch = document.getElementById('headerSearch');
|
||||
|
||||
const searchInput = headerSearch?.querySelector('#searchInput');
|
||||
const searchButtons = headerSearch?.querySelectorAll('button');
|
||||
const placeholderKey = 'header.search.placeholders.' + this.currentPage;
|
||||
|
||||
if (this.currentPage === 'statistics' && headerSearch) {
|
||||
headerSearch.classList.add('disabled');
|
||||
// Disable search functionality
|
||||
const searchInput = headerSearch.querySelector('#searchInput');
|
||||
const searchButtons = headerSearch.querySelectorAll('button');
|
||||
if (searchInput) {
|
||||
searchInput.disabled = true;
|
||||
searchInput.placeholder = 'Search not available on statistics page';
|
||||
// Use i18nHelpers to update placeholder
|
||||
updateElementAttribute(searchInput, 'placeholder', 'header.search.notAvailable', {}, 'Search not available on statistics page');
|
||||
}
|
||||
searchButtons.forEach(btn => btn.disabled = true);
|
||||
searchButtons?.forEach(btn => btn.disabled = true);
|
||||
} else if (headerSearch) {
|
||||
headerSearch.classList.remove('disabled');
|
||||
// Re-enable search functionality
|
||||
const searchInput = headerSearch.querySelector('#searchInput');
|
||||
const searchButtons = headerSearch.querySelectorAll('button');
|
||||
if (searchInput) {
|
||||
searchInput.disabled = false;
|
||||
// Use i18nHelpers to update placeholder
|
||||
updateElementAttribute(searchInput, 'placeholder', placeholderKey, {}, '');
|
||||
}
|
||||
searchButtons.forEach(btn => btn.disabled = false);
|
||||
searchButtons?.forEach(btn => btn.disabled = false);
|
||||
}
|
||||
}
|
||||
|
||||
updateThemeTooltip(themeToggle, currentTheme) {
|
||||
if (!themeToggle) return;
|
||||
let key;
|
||||
if (currentTheme === 'light') {
|
||||
key = 'header.theme.switchToDark';
|
||||
} else if (currentTheme === 'dark') {
|
||||
key = 'header.theme.switchToLight';
|
||||
} else {
|
||||
key = 'header.theme.toggle';
|
||||
}
|
||||
// Use i18nHelpers to update title
|
||||
updateElementAttribute(themeToggle, 'title', key, {}, '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export class ModelDuplicatesManager {
|
||||
this.updateDuplicatesBadge(this.duplicateGroups.length);
|
||||
|
||||
if (this.duplicateGroups.length === 0) {
|
||||
showToast('No duplicate models found', 'info');
|
||||
showToast('toast.duplicates.noDuplicatesFound', { type: this.modelType }, 'info');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ export class ModelDuplicatesManager {
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error finding duplicates:', error);
|
||||
showToast('Failed to find duplicates: ' + error.message, 'error');
|
||||
showToast('toast.duplicates.findFailed', { message: error.message }, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -594,7 +594,7 @@ export class ModelDuplicatesManager {
|
||||
|
||||
async deleteSelectedDuplicates() {
|
||||
if (this.selectedForDeletion.size === 0) {
|
||||
showToast('No models selected for deletion', 'info');
|
||||
showToast('toast.duplicates.noItemsSelected', { type: this.modelType }, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -609,7 +609,7 @@ export class ModelDuplicatesManager {
|
||||
modalManager.showModal('modelDuplicateDeleteModal');
|
||||
} catch (error) {
|
||||
console.error('Error preparing delete:', error);
|
||||
showToast('Error: ' + error.message, 'error');
|
||||
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,7 +640,7 @@ export class ModelDuplicatesManager {
|
||||
throw new Error(data.error || 'Unknown error deleting models');
|
||||
}
|
||||
|
||||
showToast(`Successfully deleted ${data.total_deleted} models`, 'success');
|
||||
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: this.modelType }, 'success');
|
||||
|
||||
// If models were successfully deleted
|
||||
if (data.total_deleted > 0) {
|
||||
@@ -678,7 +678,7 @@ export class ModelDuplicatesManager {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting models:', error);
|
||||
showToast('Failed to delete models: ' + error.message, 'error');
|
||||
showToast('toast.duplicates.deleteFailed', { type: this.modelType, message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,7 +745,7 @@ export class ModelDuplicatesManager {
|
||||
|
||||
// Check if already verified
|
||||
if (this.verifiedGroups.has(groupHash)) {
|
||||
showToast('This group has already been verified', 'info');
|
||||
showToast('toast.models.verificationAlreadyDone', {}, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -793,14 +793,14 @@ export class ModelDuplicatesManager {
|
||||
|
||||
// Show appropriate toast message
|
||||
if (mismatchedFiles.length > 0) {
|
||||
showToast(`Verification complete. ${mismatchedFiles.length} file(s) have different actual hashes.`, 'warning');
|
||||
showToast('toast.models.verificationCompleteMismatch', { count: mismatchedFiles.length }, 'warning');
|
||||
} else {
|
||||
showToast('Verification complete. All files are confirmed duplicates.', 'success');
|
||||
showToast('toast.models.verificationCompleteSuccess', {}, 'success');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error verifying hashes:', error);
|
||||
showToast('Failed to verify hashes: ' + error.message, 'error');
|
||||
showToast('toast.models.verificationFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
// Hide loading state
|
||||
state.loadingManager.hide();
|
||||
|
||||
@@ -199,7 +199,7 @@ class RecipeCard {
|
||||
// Get recipe ID
|
||||
const recipeId = this.recipe.id;
|
||||
if (!recipeId) {
|
||||
showToast('Cannot send recipe: Missing recipe ID', 'error');
|
||||
showToast('toast.recipes.cannotSend', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -214,11 +214,11 @@ class RecipeCard {
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to send recipe to workflow: ', err);
|
||||
showToast('Failed to send recipe to workflow', 'error');
|
||||
showToast('toast.recipes.sendFailed', {}, 'error');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sending recipe to workflow:', error);
|
||||
showToast('Error sending recipe to workflow', 'error');
|
||||
showToast('toast.recipes.sendError', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ class RecipeCard {
|
||||
const recipeId = this.recipe.id;
|
||||
const filePath = this.recipe.file_path;
|
||||
if (!recipeId) {
|
||||
showToast('Cannot delete recipe: Missing recipe ID', 'error');
|
||||
showToast('toast.recipes.cannotDelete', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ class RecipeCard {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error showing delete confirmation:', error);
|
||||
showToast('Error showing delete confirmation', 'error');
|
||||
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ class RecipeCard {
|
||||
const recipeId = deleteModal.dataset.recipeId;
|
||||
|
||||
if (!recipeId) {
|
||||
showToast('Cannot delete recipe: Missing recipe ID', 'error');
|
||||
showToast('toast.recipes.cannotDelete', {}, 'error');
|
||||
modalManager.closeModal('deleteModal');
|
||||
return;
|
||||
}
|
||||
@@ -312,7 +312,7 @@ class RecipeCard {
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
showToast('Recipe deleted successfully', 'success');
|
||||
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
|
||||
|
||||
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
|
||||
|
||||
@@ -320,7 +320,7 @@ class RecipeCard {
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting recipe:', error);
|
||||
showToast('Error deleting recipe: ' + error.message, 'error');
|
||||
showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
|
||||
|
||||
// Reset button state
|
||||
deleteBtn.textContent = originalText;
|
||||
@@ -333,12 +333,12 @@ class RecipeCard {
|
||||
// Get recipe ID
|
||||
const recipeId = this.recipe.id;
|
||||
if (!recipeId) {
|
||||
showToast('Cannot share recipe: Missing recipe ID', 'error');
|
||||
showToast('toast.recipes.cannotShare', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading toast
|
||||
showToast('Preparing recipe for sharing...', 'info');
|
||||
showToast('toast.recipes.preparingForSharing', {}, 'info');
|
||||
|
||||
// Call the API to process the image with metadata
|
||||
fetch(`/api/recipe/${recipeId}/share`)
|
||||
@@ -363,15 +363,15 @@ class RecipeCard {
|
||||
downloadLink.click();
|
||||
document.body.removeChild(downloadLink);
|
||||
|
||||
showToast('Recipe download started', 'success');
|
||||
showToast('toast.recipes.downloadStarted', {}, 'success');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error sharing recipe:', error);
|
||||
showToast('Error sharing recipe: ' + error.message, 'error');
|
||||
showToast('toast.recipes.shareError', { message: error.message }, 'error');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sharing recipe:', error);
|
||||
showToast('Error preparing recipe for sharing', 'error');
|
||||
showToast('toast.recipes.sharePreparationError', {}, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,7 +526,7 @@ class RecipeModal {
|
||||
updateRecipeMetadata(this.filePath, { title: newTitle })
|
||||
.then(data => {
|
||||
// Show success toast
|
||||
showToast('Recipe name updated successfully', 'success');
|
||||
showToast('toast.recipes.nameUpdated', {}, 'success');
|
||||
|
||||
// Update the current recipe object
|
||||
this.currentRecipe.title = newTitle;
|
||||
@@ -596,7 +596,7 @@ class RecipeModal {
|
||||
updateRecipeMetadata(this.filePath, { tags: newTags })
|
||||
.then(data => {
|
||||
// Show success toast
|
||||
showToast('Recipe tags updated successfully', 'success');
|
||||
showToast('toast.recipes.tagsUpdated', {}, 'success');
|
||||
|
||||
// Update the current recipe object
|
||||
this.currentRecipe.tags = newTags;
|
||||
@@ -717,7 +717,7 @@ class RecipeModal {
|
||||
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl })
|
||||
.then(data => {
|
||||
// Show success toast
|
||||
showToast('Source URL updated successfully', 'success');
|
||||
showToast('toast.recipes.sourceUrlUpdated', {}, 'success');
|
||||
|
||||
// Update source URL in the UI
|
||||
sourceUrlText.textContent = newSourceUrl || 'No source URL';
|
||||
@@ -778,7 +778,7 @@ class RecipeModal {
|
||||
// Fetch recipe syntax from backend and copy to clipboard
|
||||
async fetchAndCopyRecipeSyntax() {
|
||||
if (!this.recipeId) {
|
||||
showToast('No recipe ID available', 'error');
|
||||
showToast('toast.recipes.noRecipeId', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -800,7 +800,7 @@ class RecipeModal {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching recipe syntax:', error);
|
||||
showToast(`Error copying recipe syntax: ${error.message}`, 'error');
|
||||
showToast('toast.recipes.copyFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -817,7 +817,7 @@ class RecipeModal {
|
||||
console.log("missingLoras", missingLoras);
|
||||
|
||||
if (missingLoras.length === 0) {
|
||||
showToast('No missing LoRAs to download', 'info');
|
||||
showToast('toast.recipes.noMissingLoras', {}, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -856,7 +856,7 @@ class RecipeModal {
|
||||
const validLoras = lorasWithVersionInfo.filter(lora => lora !== null);
|
||||
|
||||
if (validLoras.length === 0) {
|
||||
showToast('Failed to get information for missing LoRAs', 'error');
|
||||
showToast('toast.recipes.missingLorasInfoFailed', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -902,7 +902,7 @@ class RecipeModal {
|
||||
window.importManager.downloadMissingLoras(recipeData, this.currentRecipe.id);
|
||||
} catch (error) {
|
||||
console.error("Error downloading missing LoRAs:", error);
|
||||
showToast('Error preparing LoRAs for download', 'error');
|
||||
showToast('toast.recipes.preparingForDownloadFailed', {}, 'error');
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
@@ -988,7 +988,7 @@ class RecipeModal {
|
||||
|
||||
async reconnectLora(loraIndex, inputValue) {
|
||||
if (!inputValue || !inputValue.trim()) {
|
||||
showToast('Please enter a LoRA name or syntax', 'error');
|
||||
showToast('toast.recipes.enterLoraName', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1026,7 +1026,7 @@ class RecipeModal {
|
||||
this.currentRecipe.loras[loraIndex] = result.updated_lora;
|
||||
|
||||
// Show success message
|
||||
showToast('LoRA reconnected successfully', 'success');
|
||||
showToast('toast.recipes.reconnectedSuccessfully', {}, 'success');
|
||||
|
||||
// Refresh modal to show updated content
|
||||
setTimeout(() => {
|
||||
@@ -1037,11 +1037,11 @@ class RecipeModal {
|
||||
loras: this.currentRecipe.loras
|
||||
});
|
||||
} else {
|
||||
showToast(`Error: ${result.error}`, 'error');
|
||||
showToast('toast.recipes.reconnectFailed', { message: result.error }, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reconnecting LoRA:', error);
|
||||
showToast(`Error reconnecting LoRA: ${error.message}`, 'error');
|
||||
showToast('toast.recipes.reconnectFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
|
||||
977
static/js/components/SidebarManager.js
Normal file
977
static/js/components/SidebarManager.js
Normal file
@@ -0,0 +1,977 @@
|
||||
/**
|
||||
* SidebarManager - Manages hierarchical folder navigation sidebar
|
||||
*/
|
||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
|
||||
export class SidebarManager {
|
||||
constructor() {
|
||||
this.pageControls = null;
|
||||
this.pageType = null;
|
||||
this.treeData = {};
|
||||
this.selectedPath = '';
|
||||
this.expandedNodes = new Set();
|
||||
this.isVisible = true;
|
||||
this.isPinned = false;
|
||||
this.apiClient = null;
|
||||
this.openDropdown = null;
|
||||
this.hoverTimeout = null;
|
||||
this.isHovering = false;
|
||||
this.isInitialized = false;
|
||||
this.displayMode = 'tree'; // 'tree' or 'list'
|
||||
this.foldersList = [];
|
||||
|
||||
// Bind methods
|
||||
this.handleTreeClick = this.handleTreeClick.bind(this);
|
||||
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
|
||||
this.handleDocumentClick = this.handleDocumentClick.bind(this);
|
||||
this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this);
|
||||
this.handlePinToggle = this.handlePinToggle.bind(this);
|
||||
this.handleCollapseAll = this.handleCollapseAll.bind(this);
|
||||
this.handleMouseEnter = this.handleMouseEnter.bind(this);
|
||||
this.handleMouseLeave = this.handleMouseLeave.bind(this);
|
||||
this.handleHoverAreaEnter = this.handleHoverAreaEnter.bind(this);
|
||||
this.handleHoverAreaLeave = this.handleHoverAreaLeave.bind(this);
|
||||
this.updateContainerMargin = this.updateContainerMargin.bind(this);
|
||||
this.handleDisplayModeToggle = this.handleDisplayModeToggle.bind(this);
|
||||
this.handleFolderListClick = this.handleFolderListClick.bind(this);
|
||||
}
|
||||
|
||||
async initialize(pageControls) {
|
||||
// Clean up previous initialization if exists
|
||||
if (this.isInitialized) {
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
this.pageControls = pageControls;
|
||||
this.pageType = pageControls.pageType;
|
||||
this.apiClient = getModelApiClient();
|
||||
|
||||
// Set initial sidebar state immediately (hidden by default)
|
||||
this.setInitialSidebarState();
|
||||
|
||||
this.setupEventHandlers();
|
||||
this.updateSidebarTitle();
|
||||
this.restoreSidebarState();
|
||||
await this.loadFolderTree();
|
||||
this.restoreSelectedFolder();
|
||||
|
||||
// Apply final state with animation after everything is loaded
|
||||
this.applyFinalSidebarState();
|
||||
|
||||
// Update container margin based on initial sidebar state
|
||||
this.updateContainerMargin();
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log(`SidebarManager initialized for ${this.pageType} page`);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (!this.isInitialized) return;
|
||||
|
||||
// Clear any pending timeouts
|
||||
if (this.hoverTimeout) {
|
||||
clearTimeout(this.hoverTimeout);
|
||||
this.hoverTimeout = null;
|
||||
}
|
||||
|
||||
// Clean up event handlers
|
||||
this.removeEventHandlers();
|
||||
|
||||
// Reset state
|
||||
this.pageControls = null;
|
||||
this.pageType = null;
|
||||
this.treeData = {};
|
||||
this.selectedPath = '';
|
||||
this.expandedNodes = new Set();
|
||||
this.openDropdown = null;
|
||||
this.isHovering = false;
|
||||
this.apiClient = null;
|
||||
this.isInitialized = false;
|
||||
|
||||
// Reset container margin
|
||||
const container = document.querySelector('.container');
|
||||
if (container) {
|
||||
container.style.marginLeft = '';
|
||||
}
|
||||
|
||||
// Remove resize event listener
|
||||
window.removeEventListener('resize', this.updateContainerMargin);
|
||||
|
||||
console.log('SidebarManager cleaned up');
|
||||
}
|
||||
|
||||
removeEventHandlers() {
|
||||
const pinToggleBtn = document.getElementById('sidebarPinToggle');
|
||||
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
||||
const folderTree = document.getElementById('sidebarFolderTree');
|
||||
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
|
||||
const sidebarHeader = document.getElementById('sidebarHeader');
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
||||
const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle');
|
||||
|
||||
if (pinToggleBtn) {
|
||||
pinToggleBtn.removeEventListener('click', this.handlePinToggle);
|
||||
}
|
||||
if (collapseAllBtn) {
|
||||
collapseAllBtn.removeEventListener('click', this.handleCollapseAll);
|
||||
}
|
||||
if (folderTree) {
|
||||
folderTree.removeEventListener('click', this.handleTreeClick);
|
||||
}
|
||||
if (sidebarBreadcrumbNav) {
|
||||
sidebarBreadcrumbNav.removeEventListener('click', this.handleBreadcrumbClick);
|
||||
}
|
||||
if (sidebarHeader) {
|
||||
sidebarHeader.removeEventListener('click', this.handleSidebarHeaderClick);
|
||||
}
|
||||
if (sidebar) {
|
||||
sidebar.removeEventListener('mouseenter', this.handleMouseEnter);
|
||||
sidebar.removeEventListener('mouseleave', this.handleMouseLeave);
|
||||
}
|
||||
if (hoverArea) {
|
||||
hoverArea.removeEventListener('mouseenter', this.handleHoverAreaEnter);
|
||||
hoverArea.removeEventListener('mouseleave', this.handleHoverAreaLeave);
|
||||
}
|
||||
|
||||
// Remove document click handler
|
||||
document.removeEventListener('click', this.handleDocumentClick);
|
||||
|
||||
// Remove resize event handler
|
||||
window.removeEventListener('resize', this.updateContainerMargin);
|
||||
|
||||
if (displayModeToggleBtn) {
|
||||
displayModeToggleBtn.removeEventListener('click', this.handleDisplayModeToggle);
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.apiClient = getModelApiClient();
|
||||
|
||||
// Set initial sidebar state immediately (hidden by default)
|
||||
this.setInitialSidebarState();
|
||||
|
||||
this.setupEventHandlers();
|
||||
this.updateSidebarTitle();
|
||||
this.restoreSidebarState();
|
||||
await this.loadFolderTree();
|
||||
this.restoreSelectedFolder();
|
||||
|
||||
// Apply final state with animation after everything is loaded
|
||||
this.applyFinalSidebarState();
|
||||
|
||||
// Update container margin based on initial sidebar state
|
||||
this.updateContainerMargin();
|
||||
}
|
||||
|
||||
setInitialSidebarState() {
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
||||
|
||||
if (!sidebar || !hoverArea) return;
|
||||
|
||||
// Get stored pin state
|
||||
const isPinned = getStorageItem(`${this.pageType}_sidebarPinned`, true);
|
||||
this.isPinned = isPinned;
|
||||
|
||||
// Sidebar starts hidden by default (CSS handles this)
|
||||
// Just set up the hover area state
|
||||
if (window.innerWidth <= 1024) {
|
||||
hoverArea.classList.add('disabled');
|
||||
} else if (this.isPinned) {
|
||||
hoverArea.classList.add('disabled');
|
||||
} else {
|
||||
hoverArea.classList.remove('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
applyFinalSidebarState() {
|
||||
// Use requestAnimationFrame to ensure DOM is ready
|
||||
requestAnimationFrame(() => {
|
||||
this.updateAutoHideState();
|
||||
});
|
||||
}
|
||||
|
||||
updateSidebarTitle() {
|
||||
const sidebarTitle = document.getElementById('sidebarTitle');
|
||||
if (sidebarTitle) {
|
||||
sidebarTitle.textContent = `${this.apiClient.apiConfig.config.displayName} Root`;
|
||||
}
|
||||
}
|
||||
|
||||
setupEventHandlers() {
|
||||
// Sidebar header (root selection) - only trigger on title area
|
||||
const sidebarHeader = document.getElementById('sidebarHeader');
|
||||
if (sidebarHeader) {
|
||||
sidebarHeader.addEventListener('click', this.handleSidebarHeaderClick);
|
||||
}
|
||||
|
||||
// Pin toggle button
|
||||
const pinToggleBtn = document.getElementById('sidebarPinToggle');
|
||||
if (pinToggleBtn) {
|
||||
pinToggleBtn.addEventListener('click', this.handlePinToggle);
|
||||
}
|
||||
|
||||
// Collapse all button
|
||||
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
||||
if (collapseAllBtn) {
|
||||
collapseAllBtn.addEventListener('click', this.handleCollapseAll);
|
||||
}
|
||||
|
||||
// Tree click handler
|
||||
const folderTree = document.getElementById('sidebarFolderTree');
|
||||
if (folderTree) {
|
||||
folderTree.addEventListener('click', this.handleTreeClick);
|
||||
}
|
||||
|
||||
// Breadcrumb click handler
|
||||
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
|
||||
if (sidebarBreadcrumbNav) {
|
||||
sidebarBreadcrumbNav.addEventListener('click', this.handleBreadcrumbClick);
|
||||
}
|
||||
|
||||
// Hover detection for auto-hide
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
||||
|
||||
if (sidebar) {
|
||||
sidebar.addEventListener('mouseenter', this.handleMouseEnter);
|
||||
sidebar.addEventListener('mouseleave', this.handleMouseLeave);
|
||||
}
|
||||
|
||||
if (hoverArea) {
|
||||
hoverArea.addEventListener('mouseenter', this.handleHoverAreaEnter);
|
||||
hoverArea.addEventListener('mouseleave', this.handleHoverAreaLeave);
|
||||
}
|
||||
|
||||
// Close sidebar when clicking outside on mobile
|
||||
document.addEventListener('click', (e) => {
|
||||
if (window.innerWidth <= 1024 && this.isVisible) {
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
|
||||
if (sidebar && !sidebar.contains(e.target)) {
|
||||
this.hideSidebar();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => {
|
||||
this.updateAutoHideState();
|
||||
this.updateContainerMargin();
|
||||
});
|
||||
|
||||
// Add document click handler for closing dropdowns
|
||||
document.addEventListener('click', this.handleDocumentClick);
|
||||
|
||||
// Add dedicated resize listener for container margin updates
|
||||
window.addEventListener('resize', this.updateContainerMargin);
|
||||
|
||||
// Display mode toggle button
|
||||
const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle');
|
||||
if (displayModeToggleBtn) {
|
||||
displayModeToggleBtn.addEventListener('click', this.handleDisplayModeToggle);
|
||||
}
|
||||
}
|
||||
|
||||
handleDocumentClick(event) {
|
||||
// Close open dropdown when clicking outside
|
||||
if (this.openDropdown && !event.target.closest('.breadcrumb-dropdown')) {
|
||||
this.closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
handleSidebarHeaderClick(event) {
|
||||
// Only trigger root selection if clicking on the title area, not the buttons
|
||||
if (!event.target.closest('.sidebar-header-actions')) {
|
||||
this.selectFolder(null);
|
||||
}
|
||||
}
|
||||
|
||||
handlePinToggle(event) {
|
||||
event.stopPropagation();
|
||||
this.isPinned = !this.isPinned;
|
||||
this.updateAutoHideState();
|
||||
this.updatePinButton();
|
||||
this.saveSidebarState();
|
||||
this.updateContainerMargin();
|
||||
}
|
||||
|
||||
handleCollapseAll(event) {
|
||||
event.stopPropagation();
|
||||
this.expandedNodes.clear();
|
||||
this.renderFolderDisplay();
|
||||
this.saveExpandedState();
|
||||
}
|
||||
|
||||
handleMouseEnter() {
|
||||
this.isHovering = true;
|
||||
if (this.hoverTimeout) {
|
||||
clearTimeout(this.hoverTimeout);
|
||||
this.hoverTimeout = null;
|
||||
}
|
||||
|
||||
if (!this.isPinned) {
|
||||
this.showSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseLeave() {
|
||||
this.isHovering = false;
|
||||
if (!this.isPinned) {
|
||||
this.hoverTimeout = setTimeout(() => {
|
||||
if (!this.isHovering) {
|
||||
this.hideSidebar();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
handleHoverAreaEnter() {
|
||||
if (!this.isPinned) {
|
||||
this.showSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
handleHoverAreaLeave() {
|
||||
// Let the sidebar's mouse leave handler deal with hiding
|
||||
}
|
||||
|
||||
showSidebar() {
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
if (sidebar && !this.isPinned) {
|
||||
sidebar.classList.add('hover-active');
|
||||
this.isVisible = true;
|
||||
this.updateContainerMargin();
|
||||
}
|
||||
}
|
||||
|
||||
hideSidebar() {
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
if (sidebar && !this.isPinned) {
|
||||
sidebar.classList.remove('hover-active');
|
||||
this.isVisible = false;
|
||||
this.updateContainerMargin();
|
||||
}
|
||||
}
|
||||
|
||||
updateAutoHideState() {
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
||||
|
||||
if (!sidebar || !hoverArea) return;
|
||||
|
||||
if (window.innerWidth <= 1024) {
|
||||
// Mobile: always use collapsed state
|
||||
sidebar.classList.remove('auto-hide', 'hover-active', 'visible');
|
||||
sidebar.classList.add('collapsed');
|
||||
hoverArea.classList.add('disabled');
|
||||
this.isVisible = false;
|
||||
} else if (this.isPinned) {
|
||||
// Desktop pinned: always visible
|
||||
sidebar.classList.remove('auto-hide', 'collapsed', 'hover-active');
|
||||
sidebar.classList.add('visible');
|
||||
hoverArea.classList.add('disabled');
|
||||
this.isVisible = true;
|
||||
} else {
|
||||
// Desktop auto-hide: use hover detection
|
||||
sidebar.classList.remove('collapsed', 'visible');
|
||||
sidebar.classList.add('auto-hide');
|
||||
hoverArea.classList.remove('disabled');
|
||||
|
||||
if (this.isHovering) {
|
||||
sidebar.classList.add('hover-active');
|
||||
this.isVisible = true;
|
||||
} else {
|
||||
sidebar.classList.remove('hover-active');
|
||||
this.isVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update container margin when sidebar state changes
|
||||
this.updateContainerMargin();
|
||||
}
|
||||
|
||||
// New method to update container margin based on sidebar state
|
||||
updateContainerMargin() {
|
||||
const container = document.querySelector('.container');
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
|
||||
if (!container || !sidebar) return;
|
||||
|
||||
// Reset margin to default
|
||||
container.style.marginLeft = '';
|
||||
|
||||
// Only adjust margin if sidebar is visible and pinned
|
||||
if ((this.isPinned || this.isHovering) && this.isVisible) {
|
||||
const sidebarWidth = sidebar.offsetWidth;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const containerWidth = container.offsetWidth;
|
||||
|
||||
// Check if there's enough space for both sidebar and container
|
||||
// We need: sidebar width + container width + some padding < viewport width
|
||||
if (sidebarWidth + containerWidth + sidebarWidth > viewportWidth) {
|
||||
// Not enough space, push container to the right
|
||||
container.style.marginLeft = `${sidebarWidth + 10}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatePinButton() {
|
||||
const pinBtn = document.getElementById('sidebarPinToggle');
|
||||
if (pinBtn) {
|
||||
pinBtn.classList.toggle('active', this.isPinned);
|
||||
pinBtn.title = this.isPinned
|
||||
? translate('sidebar.unpinSidebar')
|
||||
: translate('sidebar.pinSidebar');
|
||||
}
|
||||
}
|
||||
|
||||
async loadFolderTree() {
|
||||
try {
|
||||
if (this.displayMode === 'tree') {
|
||||
const response = await this.apiClient.fetchUnifiedFolderTree();
|
||||
this.treeData = response.tree || {};
|
||||
} else {
|
||||
const response = await this.apiClient.fetchModelFolders();
|
||||
this.foldersList = response.folders || [];
|
||||
}
|
||||
this.renderFolderDisplay();
|
||||
} catch (error) {
|
||||
console.error('Failed to load folder data:', error);
|
||||
this.renderEmptyState();
|
||||
}
|
||||
}
|
||||
|
||||
renderFolderDisplay() {
|
||||
if (this.displayMode === 'tree') {
|
||||
this.renderTree();
|
||||
} else {
|
||||
this.renderFolderList();
|
||||
}
|
||||
}
|
||||
|
||||
renderTree() {
|
||||
const folderTree = document.getElementById('sidebarFolderTree');
|
||||
if (!folderTree) return;
|
||||
|
||||
if (!this.treeData || Object.keys(this.treeData).length === 0) {
|
||||
this.renderEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
folderTree.innerHTML = this.renderTreeNode(this.treeData, '');
|
||||
}
|
||||
|
||||
renderTreeNode(nodeData, basePath) {
|
||||
const entries = Object.entries(nodeData);
|
||||
if (entries.length === 0) return '';
|
||||
|
||||
return entries.map(([folderName, children]) => {
|
||||
const currentPath = basePath ? `${basePath}/${folderName}` : folderName;
|
||||
const hasChildren = Object.keys(children).length > 0;
|
||||
const isExpanded = this.expandedNodes.has(currentPath);
|
||||
const isSelected = this.selectedPath === currentPath;
|
||||
|
||||
return `
|
||||
<div class="sidebar-tree-node" data-path="${currentPath}">
|
||||
<div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''}">
|
||||
<div class="sidebar-tree-expand-icon ${isExpanded ? 'expanded' : ''}"
|
||||
style="${hasChildren ? '' : 'opacity: 0; pointer-events: none;'}">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</div>
|
||||
<i class="fas fa-folder sidebar-tree-folder-icon"></i>
|
||||
<div class="sidebar-tree-folder-name" title="${folderName}">${folderName}</div>
|
||||
</div>
|
||||
${hasChildren ? `
|
||||
<div class="sidebar-tree-children ${isExpanded ? 'expanded' : ''}">
|
||||
${this.renderTreeNode(children, currentPath)}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
renderEmptyState() {
|
||||
const folderTree = document.getElementById('sidebarFolderTree');
|
||||
if (!folderTree) return;
|
||||
|
||||
folderTree.innerHTML = `
|
||||
<div class="sidebar-tree-placeholder">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<div>No folders found</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderFolderList() {
|
||||
const folderTree = document.getElementById('sidebarFolderTree');
|
||||
if (!folderTree) return;
|
||||
|
||||
if (!this.foldersList || this.foldersList.length === 0) {
|
||||
this.renderEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
const foldersHtml = this.foldersList.map(folder => {
|
||||
const displayName = folder === '' ? '/' : folder;
|
||||
const isSelected = this.selectedPath === folder;
|
||||
|
||||
return `
|
||||
<div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${folder}">
|
||||
<div class="sidebar-node-content">
|
||||
<i class="fas fa-folder sidebar-folder-icon"></i>
|
||||
<div class="sidebar-folder-name" title="${displayName}">${displayName}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
folderTree.innerHTML = foldersHtml;
|
||||
}
|
||||
|
||||
handleTreeClick(event) {
|
||||
if (this.displayMode === 'list') {
|
||||
this.handleFolderListClick(event);
|
||||
return;
|
||||
}
|
||||
|
||||
const expandIcon = event.target.closest('.sidebar-tree-expand-icon');
|
||||
const nodeContent = event.target.closest('.sidebar-tree-node-content');
|
||||
|
||||
if (expandIcon) {
|
||||
// Toggle expand/collapse
|
||||
const treeNode = expandIcon.closest('.sidebar-tree-node');
|
||||
const path = treeNode.dataset.path;
|
||||
const children = treeNode.querySelector('.sidebar-tree-children');
|
||||
|
||||
if (this.expandedNodes.has(path)) {
|
||||
this.expandedNodes.delete(path);
|
||||
expandIcon.classList.remove('expanded');
|
||||
if (children) children.classList.remove('expanded');
|
||||
} else {
|
||||
this.expandedNodes.add(path);
|
||||
expandIcon.classList.add('expanded');
|
||||
if (children) children.classList.add('expanded');
|
||||
}
|
||||
|
||||
this.saveExpandedState();
|
||||
} else if (nodeContent) {
|
||||
// Select folder
|
||||
const treeNode = nodeContent.closest('.sidebar-tree-node');
|
||||
const path = treeNode.dataset.path;
|
||||
this.selectFolder(path);
|
||||
}
|
||||
}
|
||||
|
||||
handleBreadcrumbClick(event) {
|
||||
const breadcrumbItem = event.target.closest('.sidebar-breadcrumb-item');
|
||||
const dropdownItem = event.target.closest('.breadcrumb-dropdown-item');
|
||||
|
||||
if (dropdownItem) {
|
||||
// Handle dropdown item selection
|
||||
const path = dropdownItem.dataset.path || '';
|
||||
this.selectFolder(path);
|
||||
this.closeDropdown();
|
||||
} else if (breadcrumbItem) {
|
||||
// Handle breadcrumb item click
|
||||
const path = breadcrumbItem.dataset.path || null; // null for showing all models
|
||||
const isPlaceholder = breadcrumbItem.classList.contains('placeholder');
|
||||
const isActive = breadcrumbItem.classList.contains('active');
|
||||
const dropdown = breadcrumbItem.closest('.breadcrumb-dropdown');
|
||||
|
||||
if (isPlaceholder || (isActive && path === this.selectedPath)) {
|
||||
// Open dropdown for placeholders or active items
|
||||
// Close any open dropdown first
|
||||
if (this.openDropdown && this.openDropdown !== dropdown) {
|
||||
this.openDropdown.classList.remove('open');
|
||||
}
|
||||
|
||||
// Toggle current dropdown
|
||||
dropdown.classList.toggle('open');
|
||||
|
||||
// Update open dropdown reference
|
||||
this.openDropdown = dropdown.classList.contains('open') ? dropdown : null;
|
||||
} else {
|
||||
// Navigate to the selected path
|
||||
this.selectFolder(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeDropdown() {
|
||||
if (this.openDropdown) {
|
||||
this.openDropdown.classList.remove('open');
|
||||
this.openDropdown = null;
|
||||
}
|
||||
}
|
||||
|
||||
async selectFolder(path) {
|
||||
// Update selected path
|
||||
this.selectedPath = path;
|
||||
|
||||
// Update UI
|
||||
this.updateTreeSelection();
|
||||
this.updateBreadcrumbs();
|
||||
this.updateSidebarHeader();
|
||||
|
||||
// Update page state
|
||||
this.pageControls.pageState.activeFolder = path;
|
||||
setStorageItem(`${this.pageType}_activeFolder`, path);
|
||||
|
||||
// Reload models with new filter
|
||||
await this.pageControls.resetAndReload();
|
||||
|
||||
// Auto-hide sidebar on mobile after selection
|
||||
if (window.innerWidth <= 1024) {
|
||||
this.hideSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
handleFolderListClick(event) {
|
||||
const folderItem = event.target.closest('.sidebar-folder-item');
|
||||
|
||||
if (folderItem) {
|
||||
const path = folderItem.dataset.path;
|
||||
this.selectFolder(path);
|
||||
}
|
||||
}
|
||||
|
||||
handleDisplayModeToggle(event) {
|
||||
event.stopPropagation();
|
||||
this.displayMode = this.displayMode === 'tree' ? 'list' : 'tree';
|
||||
this.updateDisplayModeButton();
|
||||
this.updateCollapseAllButton();
|
||||
this.updateSearchRecursiveOption();
|
||||
this.saveDisplayMode();
|
||||
this.loadFolderTree(); // Reload with new display mode
|
||||
}
|
||||
|
||||
updateDisplayModeButton() {
|
||||
const displayModeBtn = document.getElementById('sidebarDisplayModeToggle');
|
||||
if (displayModeBtn) {
|
||||
const icon = displayModeBtn.querySelector('i');
|
||||
if (this.displayMode === 'tree') {
|
||||
icon.className = 'fas fa-sitemap';
|
||||
displayModeBtn.title = translate('sidebar.switchToListView');
|
||||
} else {
|
||||
icon.className = 'fas fa-list';
|
||||
displayModeBtn.title = translate('sidebar.switchToTreeView');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateCollapseAllButton() {
|
||||
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
||||
if (collapseAllBtn) {
|
||||
if (this.displayMode === 'list') {
|
||||
collapseAllBtn.disabled = true;
|
||||
collapseAllBtn.classList.add('disabled');
|
||||
collapseAllBtn.title = translate('sidebar.collapseAllDisabled');
|
||||
} else {
|
||||
collapseAllBtn.disabled = false;
|
||||
collapseAllBtn.classList.remove('disabled');
|
||||
collapseAllBtn.title = translate('sidebar.collapseAll');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateSearchRecursiveOption() {
|
||||
this.pageControls.pageState.searchOptions.recursive = this.displayMode === 'tree';
|
||||
}
|
||||
|
||||
updateTreeSelection() {
|
||||
const folderTree = document.getElementById('sidebarFolderTree');
|
||||
if (!folderTree) return;
|
||||
|
||||
if (this.displayMode === 'list') {
|
||||
// Remove all selections in list mode
|
||||
folderTree.querySelectorAll('.sidebar-folder-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Add selection to current path
|
||||
if (this.selectedPath !== null) {
|
||||
const selectedItem = folderTree.querySelector(`[data-path="${this.selectedPath}"]`);
|
||||
if (selectedItem) {
|
||||
selectedItem.classList.add('selected');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
folderTree.querySelectorAll('.sidebar-tree-node-content').forEach(node => {
|
||||
node.classList.remove('selected');
|
||||
});
|
||||
|
||||
if (this.selectedPath) {
|
||||
const selectedNode = folderTree.querySelector(`[data-path="${this.selectedPath}"] .sidebar-tree-node-content`);
|
||||
if (selectedNode) {
|
||||
selectedNode.classList.add('selected');
|
||||
this.expandPathParents(this.selectedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expandPathParents(path) {
|
||||
if (!path) return;
|
||||
|
||||
const parts = path.split('/');
|
||||
let currentPath = '';
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
|
||||
this.expandedNodes.add(currentPath);
|
||||
}
|
||||
|
||||
this.renderTree();
|
||||
}
|
||||
|
||||
// Get sibling folders for a given path level
|
||||
getSiblingFolders(pathParts, level) {
|
||||
if (level === 0) {
|
||||
// Root level siblings are top-level folders
|
||||
return Object.keys(this.treeData);
|
||||
}
|
||||
|
||||
// Navigate to the parent folder to get siblings
|
||||
let currentNode = this.treeData;
|
||||
for (let i = 0; i < level; i++) {
|
||||
if (!currentNode[pathParts[i]]) {
|
||||
return [];
|
||||
}
|
||||
currentNode = currentNode[pathParts[i]];
|
||||
}
|
||||
|
||||
return Object.keys(currentNode);
|
||||
}
|
||||
|
||||
// Get child folders for a given path
|
||||
getChildFolders(path) {
|
||||
if (!path) {
|
||||
return Object.keys(this.treeData);
|
||||
}
|
||||
|
||||
const parts = path.split('/');
|
||||
let currentNode = this.treeData;
|
||||
|
||||
for (const part of parts) {
|
||||
if (!currentNode[part]) {
|
||||
return [];
|
||||
}
|
||||
currentNode = currentNode[part];
|
||||
}
|
||||
|
||||
return Object.keys(currentNode);
|
||||
}
|
||||
|
||||
updateBreadcrumbs() {
|
||||
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
|
||||
if (!sidebarBreadcrumbNav) return;
|
||||
|
||||
const parts = this.selectedPath ? this.selectedPath.split('/') : [];
|
||||
let currentPath = '';
|
||||
|
||||
// Start with root breadcrumb
|
||||
const rootSiblings = Object.keys(this.treeData);
|
||||
const breadcrumbs = [`
|
||||
<div class="breadcrumb-dropdown">
|
||||
<span class="sidebar-breadcrumb-item ${this.selectedPath == null ? 'active' : ''}" data-path="">
|
||||
<i class="fas fa-home"></i> ${this.apiClient.apiConfig.config.displayName} root
|
||||
</span>
|
||||
</div>
|
||||
`];
|
||||
|
||||
// Add separator and placeholder for next level if we're at root
|
||||
if (!this.selectedPath) {
|
||||
const nextLevelFolders = rootSiblings;
|
||||
if (nextLevelFolders.length > 0) {
|
||||
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
|
||||
breadcrumbs.push(`
|
||||
<div class="breadcrumb-dropdown">
|
||||
<span class="sidebar-breadcrumb-item placeholder">
|
||||
--
|
||||
<span class="breadcrumb-dropdown-indicator">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</span>
|
||||
</span>
|
||||
<div class="breadcrumb-dropdown-menu">
|
||||
${nextLevelFolders.map(folder => `
|
||||
<div class="breadcrumb-dropdown-item" data-path="${folder}">
|
||||
${folder}
|
||||
</div>`).join('')
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add breadcrumb items for each path segment
|
||||
parts.forEach((part, index) => {
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||
const isLast = index === parts.length - 1;
|
||||
|
||||
// Get siblings for this level
|
||||
const siblings = this.getSiblingFolders(parts, index);
|
||||
|
||||
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
|
||||
breadcrumbs.push(`
|
||||
<div class="breadcrumb-dropdown">
|
||||
<span class="sidebar-breadcrumb-item ${isLast ? 'active' : ''}" data-path="${currentPath}">
|
||||
${part}
|
||||
${siblings.length > 1 ? `
|
||||
<span class="breadcrumb-dropdown-indicator">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</span>
|
||||
` : ''}
|
||||
</span>
|
||||
${siblings.length > 1 ? `
|
||||
<div class="breadcrumb-dropdown-menu">
|
||||
${siblings.map(folder => `
|
||||
<div class="breadcrumb-dropdown-item ${folder === part ? 'active' : ''}"
|
||||
data-path="${currentPath.replace(part, folder)}">
|
||||
${folder}
|
||||
</div>`).join('')
|
||||
}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Add separator and placeholder for next level if not the last item
|
||||
if (isLast) {
|
||||
const childFolders = this.getChildFolders(currentPath);
|
||||
if (childFolders.length > 0) {
|
||||
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
|
||||
breadcrumbs.push(`
|
||||
<div class="breadcrumb-dropdown">
|
||||
<span class="sidebar-breadcrumb-item placeholder">
|
||||
--
|
||||
<span class="breadcrumb-dropdown-indicator">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</span>
|
||||
</span>
|
||||
<div class="breadcrumb-dropdown-menu">
|
||||
${childFolders.map(folder => `
|
||||
<div class="breadcrumb-dropdown-item" data-path="${currentPath}/${folder}">
|
||||
${folder}
|
||||
</div>`).join('')
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sidebarBreadcrumbNav.innerHTML = breadcrumbs.join('');
|
||||
}
|
||||
|
||||
updateSidebarHeader() {
|
||||
const sidebarHeader = document.getElementById('sidebarHeader');
|
||||
if (!sidebarHeader) return;
|
||||
|
||||
if (this.selectedPath == null) {
|
||||
sidebarHeader.classList.add('root-selected');
|
||||
} else {
|
||||
sidebarHeader.classList.remove('root-selected');
|
||||
}
|
||||
}
|
||||
|
||||
toggleSidebar() {
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
|
||||
|
||||
if (!sidebar) return;
|
||||
|
||||
this.isVisible = !this.isVisible;
|
||||
|
||||
if (this.isVisible) {
|
||||
sidebar.classList.remove('collapsed');
|
||||
sidebar.classList.add('visible');
|
||||
} else {
|
||||
sidebar.classList.remove('visible');
|
||||
sidebar.classList.add('collapsed');
|
||||
}
|
||||
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.toggle('active', this.isVisible);
|
||||
}
|
||||
|
||||
this.saveSidebarState();
|
||||
}
|
||||
|
||||
closeSidebar() {
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
|
||||
|
||||
if (!sidebar) return;
|
||||
|
||||
this.isVisible = false;
|
||||
sidebar.classList.remove('visible');
|
||||
sidebar.classList.add('collapsed');
|
||||
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.remove('active');
|
||||
}
|
||||
|
||||
this.saveSidebarState();
|
||||
}
|
||||
|
||||
restoreSidebarState() {
|
||||
const isPinned = getStorageItem(`${this.pageType}_sidebarPinned`, true);
|
||||
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
|
||||
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
|
||||
|
||||
this.isPinned = isPinned;
|
||||
this.expandedNodes = new Set(expandedPaths);
|
||||
this.displayMode = displayMode;
|
||||
|
||||
this.updatePinButton();
|
||||
this.updateDisplayModeButton();
|
||||
this.updateCollapseAllButton();
|
||||
this.updateSearchRecursiveOption();
|
||||
}
|
||||
|
||||
restoreSelectedFolder() {
|
||||
const activeFolder = getStorageItem(`${this.pageType}_activeFolder`);
|
||||
if (activeFolder && typeof activeFolder === 'string') {
|
||||
this.selectedPath = activeFolder;
|
||||
this.updateTreeSelection();
|
||||
this.updateBreadcrumbs();
|
||||
this.updateSidebarHeader();
|
||||
} else {
|
||||
this.selectedPath = '';
|
||||
this.updateSidebarHeader();
|
||||
this.updateBreadcrumbs(); // Always update breadcrumbs
|
||||
}
|
||||
// Removed hidden class toggle since breadcrumbs are always visible now
|
||||
}
|
||||
|
||||
saveSidebarState() {
|
||||
setStorageItem(`${this.pageType}_sidebarPinned`, this.isPinned);
|
||||
}
|
||||
|
||||
saveExpandedState() {
|
||||
setStorageItem(`${this.pageType}_expandedNodes`, Array.from(this.expandedNodes));
|
||||
}
|
||||
|
||||
saveDisplayMode() {
|
||||
setStorageItem(`${this.pageType}_displayMode`, this.displayMode);
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this.loadFolderTree();
|
||||
this.restoreSelectedFolder();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export global instance
|
||||
export const sidebarManager = new SidebarManager();
|
||||
@@ -47,7 +47,7 @@ export class CheckpointsControls extends PageControls {
|
||||
// No clearCustomFilter implementation is needed for checkpoints
|
||||
// as custom filters are currently only used for LoRAs
|
||||
clearCustomFilter: async () => {
|
||||
showToast('No custom filter to clear', 'info');
|
||||
showToast('toast.filters.noCustomFilterToClear', {}, 'info');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export class EmbeddingsControls extends PageControls {
|
||||
// No clearCustomFilter implementation is needed for embeddings
|
||||
// as custom filters are currently only used for LoRAs
|
||||
clearCustomFilter: async () => {
|
||||
showToast('No custom filter to clear', 'info');
|
||||
showToast('toast.filters.noCustomFilterToClear', {}, 'info');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { getCurrentPageState, setCurrentPageType } from '../../state/index.js';
|
||||
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { sidebarManager } from '../SidebarManager.js';
|
||||
|
||||
/**
|
||||
* PageControls class - Unified control management for model pages
|
||||
@@ -23,6 +24,9 @@ export class PageControls {
|
||||
// Store API methods
|
||||
this.api = null;
|
||||
|
||||
// Use global sidebar manager
|
||||
this.sidebarManager = sidebarManager;
|
||||
|
||||
// Initialize event listeners
|
||||
this.initEventListeners();
|
||||
|
||||
@@ -55,6 +59,21 @@ export class PageControls {
|
||||
registerAPI(api) {
|
||||
this.api = api;
|
||||
console.log(`API methods registered for ${this.pageType} page`);
|
||||
|
||||
// Initialize sidebar manager after API is registered
|
||||
this.initSidebarManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize sidebar manager
|
||||
*/
|
||||
async initSidebarManager() {
|
||||
try {
|
||||
await this.sidebarManager.initialize(this);
|
||||
console.log('SidebarManager initialized');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize SidebarManager:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,17 +91,6 @@ export class PageControls {
|
||||
});
|
||||
}
|
||||
|
||||
// Use event delegation for folder tags - this is the key fix
|
||||
const folderTagsContainer = document.querySelector('.folder-tags-container');
|
||||
if (folderTagsContainer) {
|
||||
folderTagsContainer.addEventListener('click', (e) => {
|
||||
const tag = e.target.closest('.tag');
|
||||
if (tag) {
|
||||
this.handleFolderClick(tag);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh button handler
|
||||
const refreshBtn = document.querySelector('[data-action="refresh"]');
|
||||
if (refreshBtn) {
|
||||
@@ -92,12 +100,6 @@ export class PageControls {
|
||||
// Initialize dropdown functionality
|
||||
this.initDropdowns();
|
||||
|
||||
// Toggle folders button
|
||||
const toggleFoldersBtn = document.querySelector('.toggle-folders-btn');
|
||||
if (toggleFoldersBtn) {
|
||||
toggleFoldersBtn.addEventListener('click', () => this.toggleFolderTags());
|
||||
}
|
||||
|
||||
// Clear custom filter handler
|
||||
const clearFilterBtn = document.querySelector('.clear-filter');
|
||||
if (clearFilterBtn) {
|
||||
@@ -199,130 +201,6 @@ export class PageControls {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle folder selection
|
||||
* @param {HTMLElement} tagElement - The folder tag element that was clicked
|
||||
*/
|
||||
handleFolderClick(tagElement) {
|
||||
const folder = tagElement.dataset.folder;
|
||||
const wasActive = tagElement.classList.contains('active');
|
||||
|
||||
document.querySelectorAll('.folder-tags .tag').forEach(t => {
|
||||
t.classList.remove('active');
|
||||
});
|
||||
|
||||
if (!wasActive) {
|
||||
tagElement.classList.add('active');
|
||||
this.pageState.activeFolder = folder;
|
||||
setStorageItem(`${this.pageType}_activeFolder`, folder);
|
||||
} else {
|
||||
this.pageState.activeFolder = null;
|
||||
setStorageItem(`${this.pageType}_activeFolder`, null);
|
||||
}
|
||||
|
||||
this.resetAndReload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore folder filter from storage
|
||||
*/
|
||||
restoreFolderFilter() {
|
||||
const activeFolder = getStorageItem(`${this.pageType}_activeFolder`);
|
||||
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
|
||||
|
||||
if (folderTag) {
|
||||
folderTag.classList.add('active');
|
||||
this.pageState.activeFolder = activeFolder;
|
||||
this.filterByFolder(activeFolder);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter displayed cards by folder
|
||||
* @param {string} folderPath - Folder path to filter by
|
||||
*/
|
||||
filterByFolder(folderPath) {
|
||||
const cardSelector = this.pageType === 'loras' ? '.model-card' : '.checkpoint-card';
|
||||
document.querySelectorAll(cardSelector).forEach(card => {
|
||||
card.style.display = card.dataset.folder === folderPath ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the folder tags display with new folder list
|
||||
* @param {Array} folders - List of folder names
|
||||
*/
|
||||
updateFolderTags(folders) {
|
||||
const folderTagsContainer = document.querySelector('.folder-tags');
|
||||
if (!folderTagsContainer) return;
|
||||
|
||||
// Keep track of currently selected folder
|
||||
const currentFolder = this.pageState.activeFolder;
|
||||
|
||||
// Create HTML for folder tags
|
||||
const tagsHTML = folders.map(folder => {
|
||||
const isActive = folder === currentFolder;
|
||||
return `<div class="tag ${isActive ? 'active' : ''}" data-folder="${folder}">${folder}</div>`;
|
||||
}).join('');
|
||||
|
||||
// Update the container
|
||||
folderTagsContainer.innerHTML = tagsHTML;
|
||||
|
||||
// Scroll active folder into view (no need to reattach click handlers)
|
||||
const activeTag = folderTagsContainer.querySelector(`.tag[data-folder="${currentFolder}"]`);
|
||||
if (activeTag) {
|
||||
activeTag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visibility of folder tags
|
||||
*/
|
||||
toggleFolderTags() {
|
||||
const folderTags = document.querySelector('.folder-tags');
|
||||
const toggleBtn = document.querySelector('.toggle-folders-btn i');
|
||||
|
||||
if (folderTags) {
|
||||
folderTags.classList.toggle('collapsed');
|
||||
|
||||
if (folderTags.classList.contains('collapsed')) {
|
||||
// Change icon to indicate folders are hidden
|
||||
toggleBtn.className = 'fas fa-folder-plus';
|
||||
toggleBtn.parentElement.title = 'Show folder tags';
|
||||
setStorageItem('folderTagsCollapsed', 'true');
|
||||
} else {
|
||||
// Change icon to indicate folders are visible
|
||||
toggleBtn.className = 'fas fa-folder-minus';
|
||||
toggleBtn.parentElement.title = 'Hide folder tags';
|
||||
setStorageItem('folderTagsCollapsed', 'false');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize folder tags visibility based on stored preference
|
||||
*/
|
||||
initFolderTagsVisibility() {
|
||||
const isCollapsed = getStorageItem('folderTagsCollapsed');
|
||||
if (isCollapsed) {
|
||||
const folderTags = document.querySelector('.folder-tags');
|
||||
const toggleBtn = document.querySelector('.toggle-folders-btn i');
|
||||
if (folderTags) {
|
||||
folderTags.classList.add('collapsed');
|
||||
}
|
||||
if (toggleBtn) {
|
||||
toggleBtn.className = 'fas fa-folder-plus';
|
||||
toggleBtn.parentElement.title = 'Show folder tags';
|
||||
}
|
||||
} else {
|
||||
const toggleBtn = document.querySelector('.toggle-folders-btn i');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.className = 'fas fa-folder-minus';
|
||||
toggleBtn.parentElement.title = 'Hide folder tags';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load sort preference from storage
|
||||
*/
|
||||
@@ -408,9 +286,14 @@ export class PageControls {
|
||||
|
||||
try {
|
||||
await this.api.resetAndReload(updateFolders);
|
||||
|
||||
// Refresh sidebar after reload if folders were updated
|
||||
if (updateFolders && this.sidebarManager) {
|
||||
await this.sidebarManager.refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reloading ${this.pageType}:`, error);
|
||||
showToast(`Failed to reload ${this.pageType}: ${error.message}`, 'error');
|
||||
showToast('toast.controls.reloadFailed', { pageType: this.pageType, message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,9 +309,14 @@ export class PageControls {
|
||||
|
||||
try {
|
||||
await this.api.refreshModels(fullRebuild);
|
||||
|
||||
// Refresh sidebar after rebuild
|
||||
if (this.sidebarManager) {
|
||||
await this.sidebarManager.refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error ${fullRebuild ? 'rebuilding' : 'refreshing'} ${this.pageType}:`, error);
|
||||
showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${this.pageType}: ${error.message}`, 'error');
|
||||
showToast('toast.controls.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', pageType: this.pageType, message: error.message }, 'error');
|
||||
}
|
||||
|
||||
if (window.modelDuplicatesManager) {
|
||||
@@ -450,7 +338,7 @@ export class PageControls {
|
||||
await this.api.fetchFromCivitai();
|
||||
} catch (error) {
|
||||
console.error('Error fetching metadata:', error);
|
||||
showToast('Failed to fetch metadata: ' + error.message, 'error');
|
||||
showToast('toast.controls.fetchMetadataFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,7 +374,7 @@ export class PageControls {
|
||||
await this.api.clearCustomFilter();
|
||||
} catch (error) {
|
||||
console.error('Error clearing custom filter:', error);
|
||||
showToast('Failed to clear custom filter: ' + error.message, 'error');
|
||||
showToast('toast.controls.clearFilterFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,4 +435,15 @@ export class PageControls {
|
||||
console.error('Model duplicates manager not available');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
destroy() {
|
||||
// Note: We don't destroy the global sidebar manager, just clean it up
|
||||
// The global instance will be reused for other page controls
|
||||
if (this.sidebarManager && this.sidebarManager.isInitialized) {
|
||||
this.sidebarManager.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { showDeleteModal } from '../../utils/modalUtils.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
|
||||
// Add global event delegation handlers
|
||||
export function setupModelCardEventDelegation(modelType) {
|
||||
@@ -142,13 +143,13 @@ async function toggleFavorite(card) {
|
||||
});
|
||||
|
||||
if (newFavoriteState) {
|
||||
showToast('Added to favorites', 'success');
|
||||
showToast('modelCard.favorites.added', {}, 'success');
|
||||
} else {
|
||||
showToast('Removed from favorites', 'success');
|
||||
showToast('modelCard.favorites.removed', {}, 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update favorite status:', error);
|
||||
showToast('Failed to update favorite status', 'error');
|
||||
showToast('modelCard.favorites.updateFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +161,7 @@ function handleSendToWorkflow(card, replaceMode, modelType) {
|
||||
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
|
||||
} else {
|
||||
// Checkpoint send functionality - to be implemented
|
||||
showToast('Send checkpoint to workflow - feature to be implemented', 'info');
|
||||
showToast('modelCard.sendToWorkflow.checkpointNotImplemented', {}, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +171,8 @@ function handleCopyAction(card, modelType) {
|
||||
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
|
||||
// Checkpoint copy functionality - copy checkpoint name
|
||||
const checkpointName = card.dataset.file_name;
|
||||
copyToClipboard(checkpointName, 'Checkpoint name copied');
|
||||
const message = translate('modelCard.actions.checkpointNameCopied', {}, 'Checkpoint name copied');
|
||||
copyToClipboard(checkpointName, message);
|
||||
} else if (modelType === MODEL_TYPES.EMBEDDING) {
|
||||
const embeddingName = card.dataset.file_name;
|
||||
copyToClipboard(embeddingName, 'Embedding name copied');
|
||||
@@ -195,7 +197,7 @@ async function handleExampleImagesAccess(card, modelType) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for example images:', error);
|
||||
showToast('Error checking for example images', 'error');
|
||||
showToast('modelCard.exampleImages.checkError', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +216,7 @@ function handleCardClick(card, modelType) {
|
||||
}
|
||||
}
|
||||
|
||||
function showModelModalFromCard(card, modelType) {
|
||||
async function showModelModalFromCard(card, modelType) {
|
||||
// Get the appropriate preview versions map
|
||||
const previewVersionsKey = modelType;
|
||||
const previewVersions = state.pages[previewVersionsKey]?.previewVersions || new Map();
|
||||
@@ -246,7 +248,7 @@ function showModelModalFromCard(card, modelType) {
|
||||
})
|
||||
};
|
||||
|
||||
showModelModal(modelMeta, modelType);
|
||||
await showModelModal(modelMeta, modelType);
|
||||
}
|
||||
|
||||
// Function to show the example access modal (generalized for lora and checkpoint)
|
||||
@@ -277,7 +279,7 @@ function showExampleAccessModal(card, modelType) {
|
||||
// Get the model hash
|
||||
const modelHash = card.dataset.sha256;
|
||||
if (!modelHash) {
|
||||
showToast('Missing model hash information.', 'error');
|
||||
showToast('modelCard.exampleImages.missingHash', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -298,7 +300,8 @@ function showExampleAccessModal(card, modelType) {
|
||||
};
|
||||
} else {
|
||||
downloadBtn.classList.add('disabled');
|
||||
downloadBtn.setAttribute('title', 'No remote example images available for this model on Civitai');
|
||||
const noRemoteImagesTitle = translate('modelCard.exampleImages.noRemoteImagesAvailable', {}, 'No remote example images available for this model on Civitai');
|
||||
downloadBtn.setAttribute('title', noRemoteImagesTitle);
|
||||
downloadBtn.onclick = null;
|
||||
}
|
||||
}
|
||||
@@ -306,7 +309,7 @@ function showExampleAccessModal(card, modelType) {
|
||||
// Set up import button
|
||||
const importBtn = modal.querySelector('#importExamplesBtn');
|
||||
if (importBtn) {
|
||||
importBtn.onclick = () => {
|
||||
importBtn.onclick = async () => {
|
||||
modalManager.closeModal('exampleAccessModal');
|
||||
|
||||
// Get the model data from card dataset (works for both lora and checkpoint)
|
||||
@@ -333,7 +336,7 @@ function showExampleAccessModal(card, modelType) {
|
||||
}
|
||||
|
||||
// Show the model modal
|
||||
showModelModal(modelMeta, modelType);
|
||||
await showModelModal(modelMeta, modelType);
|
||||
|
||||
// Scroll to import area after modal is visible
|
||||
setTimeout(() => {
|
||||
@@ -429,14 +432,14 @@ export function createModelCard(model, modelType) {
|
||||
const previewUrl = model.preview_url || '/loras_static/images/no-preview.png';
|
||||
const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl;
|
||||
|
||||
// Determine NSFW warning text based on level
|
||||
let nsfwText = "Mature Content";
|
||||
// Determine NSFW warning text based on level with i18n support
|
||||
let nsfwText = translate('modelCard.nsfw.matureContent', {}, 'Mature Content');
|
||||
if (nsfwLevel >= NSFW_LEVELS.XXX) {
|
||||
nsfwText = "XXX-rated Content";
|
||||
nsfwText = translate('modelCard.nsfw.xxxRated', {}, 'XXX-rated Content');
|
||||
} else if (nsfwLevel >= NSFW_LEVELS.X) {
|
||||
nsfwText = "X-rated Content";
|
||||
nsfwText = translate('modelCard.nsfw.xRated', {}, 'X-rated Content');
|
||||
} else if (nsfwLevel >= NSFW_LEVELS.R) {
|
||||
nsfwText = "R-rated Content";
|
||||
nsfwText = translate('modelCard.nsfw.rRated', {}, 'R-rated Content');
|
||||
}
|
||||
|
||||
// Check if autoplayOnHover is enabled for video previews
|
||||
@@ -447,22 +450,36 @@ export function createModelCard(model, modelType) {
|
||||
// Get favorite status from model data
|
||||
const isFavorite = model.favorite === true;
|
||||
|
||||
// Generate action icons based on model type
|
||||
// Generate action icons based on model type with i18n support
|
||||
const favoriteTitle = isFavorite ?
|
||||
translate('modelCard.actions.removeFromFavorites', {}, 'Remove from favorites') :
|
||||
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
|
||||
const globeTitle = model.from_civitai ?
|
||||
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
|
||||
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
|
||||
const sendTitle = translate('modelCard.actions.sendToWorkflow', {}, 'Send to ComfyUI (Click: Append, Shift+Click: Replace)');
|
||||
const copyTitle = translate('modelCard.actions.copyLoRASyntax', {}, 'Copy LoRA Syntax');
|
||||
|
||||
const actionIcons = `
|
||||
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}"
|
||||
title="${isFavorite ? 'Remove from favorites' : 'Add to favorites'}">
|
||||
title="${favoriteTitle}">
|
||||
</i>
|
||||
<i class="fas fa-globe"
|
||||
title="${model.from_civitai ? 'View on Civitai' : 'Not available from Civitai'}"
|
||||
title="${globeTitle}"
|
||||
${!model.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
|
||||
</i>
|
||||
<i class="fas fa-paper-plane"
|
||||
title="Send to ComfyUI (Click: Append, Shift+Click: Replace)">
|
||||
title="${sendTitle}">
|
||||
</i>
|
||||
<i class="fas fa-copy"
|
||||
title="Copy LoRA Syntax">
|
||||
title="${copyTitle}">
|
||||
</i>`;
|
||||
|
||||
// Generate UI text with i18n support
|
||||
const toggleBlurTitle = translate('modelCard.actions.toggleBlur', {}, 'Toggle blur');
|
||||
const showButtonText = translate('modelCard.actions.show', {}, 'Show');
|
||||
const openExampleImagesTitle = translate('modelCard.actions.openExampleImages', {}, 'Open Example Images Folder');
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||||
${isVideo ?
|
||||
@@ -473,7 +490,7 @@ export function createModelCard(model, modelType) {
|
||||
}
|
||||
<div class="card-header">
|
||||
${shouldBlur ?
|
||||
`<button class="toggle-blur-btn" title="Toggle blur">
|
||||
`<button class="toggle-blur-btn" title="${toggleBlurTitle}">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>` : ''}
|
||||
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${model.base_model}">
|
||||
@@ -487,7 +504,7 @@ export function createModelCard(model, modelType) {
|
||||
<div class="nsfw-overlay">
|
||||
<div class="nsfw-warning">
|
||||
<p>${nsfwText}</p>
|
||||
<button class="show-content-btn">Show</button>
|
||||
<button class="show-content-btn">${showButtonText}</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
@@ -498,7 +515,7 @@ export function createModelCard(model, modelType) {
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<i class="fas fa-folder-open"
|
||||
title="Open Example Images Folder">
|
||||
title="${openExampleImagesTitle}">
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
|
||||
/**
|
||||
* ModelDescription.js
|
||||
@@ -12,7 +13,7 @@ export function setupTabSwitching() {
|
||||
const tabButtons = document.querySelectorAll('.showcase-tabs .tab-btn');
|
||||
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
button.addEventListener('click', async () => {
|
||||
// Remove active class from all tabs
|
||||
document.querySelectorAll('.showcase-tabs .tab-btn').forEach(btn =>
|
||||
btn.classList.remove('active')
|
||||
@@ -26,29 +27,65 @@ export function setupTabSwitching() {
|
||||
const tabId = `${button.dataset.tab}-tab`;
|
||||
document.getElementById(tabId).classList.add('active');
|
||||
|
||||
// If switching to description tab, make sure content is properly sized
|
||||
// If switching to description tab, load content lazily
|
||||
if (button.dataset.tab === 'description') {
|
||||
const descriptionContent = document.querySelector('.model-description-content');
|
||||
if (descriptionContent) {
|
||||
const hasContent = descriptionContent.innerHTML.trim() !== '';
|
||||
document.querySelector('.model-description-loading')?.classList.add('hidden');
|
||||
|
||||
// If no content, show a message
|
||||
if (!hasContent) {
|
||||
descriptionContent.innerHTML = '<div class="no-description">No model description available</div>';
|
||||
descriptionContent.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
await loadModelDescription();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load model description lazily
|
||||
*/
|
||||
async function loadModelDescription() {
|
||||
const descriptionContent = document.querySelector('.model-description-content');
|
||||
const descriptionLoading = document.querySelector('.model-description-loading');
|
||||
const showcaseSection = document.querySelector('.showcase-section');
|
||||
|
||||
if (!descriptionContent || !showcaseSection) return;
|
||||
|
||||
// Check if already loaded
|
||||
if (descriptionContent.dataset.loaded === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = showcaseSection.dataset.filepath;
|
||||
if (!filePath) return;
|
||||
|
||||
try {
|
||||
// Show loading state
|
||||
descriptionLoading?.classList.remove('hidden');
|
||||
descriptionContent.classList.add('hidden');
|
||||
|
||||
// Fetch description from API
|
||||
const { getModelApiClient } = await import('../../api/modelApiFactory.js');
|
||||
const description = await getModelApiClient().fetchModelDescription(filePath);
|
||||
|
||||
// Update content
|
||||
const noDescriptionText = translate('modals.model.description.noDescription', {}, 'No model description available');
|
||||
descriptionContent.innerHTML = description || `<div class="no-description">${noDescriptionText}</div>`;
|
||||
descriptionContent.dataset.loaded = 'true';
|
||||
|
||||
// Set up editing functionality
|
||||
await setupModelDescriptionEditing(filePath);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading model description:', error);
|
||||
const failedText = translate('modals.model.description.failedToLoad', {}, 'Failed to load model description');
|
||||
descriptionContent.innerHTML = `<div class="no-description">${failedText}</div>`;
|
||||
} finally {
|
||||
// Hide loading state
|
||||
descriptionLoading?.classList.add('hidden');
|
||||
descriptionContent.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up model description editing functionality
|
||||
* @param {string} filePath - File path
|
||||
*/
|
||||
export function setupModelDescriptionEditing(filePath) {
|
||||
export async function setupModelDescriptionEditing(filePath) {
|
||||
const descContent = document.querySelector('.model-description-content');
|
||||
const descContainer = document.querySelector('.model-description-container');
|
||||
if (!descContent || !descContainer) return;
|
||||
@@ -58,7 +95,9 @@ export function setupModelDescriptionEditing(filePath) {
|
||||
if (!editBtn) {
|
||||
editBtn = document.createElement('button');
|
||||
editBtn.className = 'edit-model-description-btn';
|
||||
editBtn.title = 'Edit model description';
|
||||
// Set title using i18n
|
||||
const editTitle = translate('modals.model.description.editTitle', {}, 'Edit model description');
|
||||
editBtn.title = editTitle;
|
||||
editBtn.innerHTML = '<i class="fas fa-pencil-alt"></i>';
|
||||
descContainer.insertBefore(editBtn, descContent);
|
||||
}
|
||||
@@ -115,7 +154,7 @@ export function setupModelDescriptionEditing(filePath) {
|
||||
}
|
||||
if (!newValue) {
|
||||
this.innerHTML = originalValue;
|
||||
showToast('Description cannot be empty', 'error');
|
||||
showToast('modals.model.description.validation.cannotBeEmpty', {}, 'error');
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
@@ -123,10 +162,10 @@ export function setupModelDescriptionEditing(filePath) {
|
||||
// Save to backend
|
||||
const { getModelApiClient } = await import('../../api/modelApiFactory.js');
|
||||
await getModelApiClient().saveModelMetadata(filePath, { modelDescription: newValue });
|
||||
showToast('Model description updated', 'success');
|
||||
showToast('modals.model.description.messages.updated', {}, 'success');
|
||||
} catch (err) {
|
||||
this.innerHTML = originalValue;
|
||||
showToast('Failed to update model description', 'error');
|
||||
showToast('modals.model.description.messages.updateFailed', {}, 'error');
|
||||
} finally {
|
||||
exitEditMode();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { BASE_MODELS } from '../../utils/constants.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
|
||||
/**
|
||||
* Set up model name editing functionality
|
||||
@@ -82,7 +83,7 @@ export function setupModelNameEditing(filePath) {
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
|
||||
showToast('Model name is limited to 100 characters', 'warning');
|
||||
showToast('toast.models.nameTooLong', {}, 'warning');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -97,7 +98,7 @@ export function setupModelNameEditing(filePath) {
|
||||
if (!newModelName) {
|
||||
// Restore original value if empty
|
||||
this.textContent = originalValue;
|
||||
showToast('Model name cannot be empty', 'error');
|
||||
showToast('toast.models.nameCannotBeEmpty', {}, 'error');
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
@@ -114,11 +115,11 @@ export function setupModelNameEditing(filePath) {
|
||||
|
||||
await getModelApiClient().saveModelMetadata(filePath, { model_name: newModelName });
|
||||
|
||||
showToast('Model name updated successfully', 'success');
|
||||
showToast('toast.models.nameUpdatedSuccessfully', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error updating model name:', error);
|
||||
this.textContent = originalValue; // Restore original model name
|
||||
showToast('Failed to update model name', 'error');
|
||||
showToast('toast.models.nameUpdateFailed', {}, 'error');
|
||||
} finally {
|
||||
exitEditMode();
|
||||
}
|
||||
@@ -177,13 +178,20 @@ export function setupBaseModelEditing(filePath) {
|
||||
'Stable Diffusion 2.x': [BASE_MODELS.SD_2_0, BASE_MODELS.SD_2_1],
|
||||
'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO],
|
||||
'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER],
|
||||
'Video Models': [BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO],
|
||||
'Video Models': [
|
||||
BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.HUNYUAN_VIDEO, BASE_MODELS.WAN_VIDEO,
|
||||
BASE_MODELS.WAN_VIDEO_1_3B_T2V, BASE_MODELS.WAN_VIDEO_14B_T2V,
|
||||
BASE_MODELS.WAN_VIDEO_14B_I2V_480P, BASE_MODELS.WAN_VIDEO_14B_I2V_720P,
|
||||
BASE_MODELS.WAN_VIDEO_2_2_TI2V_5B, BASE_MODELS.WAN_VIDEO_2_2_T2V_A14B,
|
||||
BASE_MODELS.WAN_VIDEO_2_2_I2V_A14B
|
||||
],
|
||||
'Flux Models': [BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, BASE_MODELS.FLUX_1_KREA],
|
||||
'Other Models': [
|
||||
BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, BASE_MODELS.AURAFLOW,
|
||||
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
|
||||
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW,
|
||||
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
|
||||
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
|
||||
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
|
||||
BASE_MODELS.QWEN, BASE_MODELS.UNKNOWN
|
||||
BASE_MODELS.UNKNOWN
|
||||
]
|
||||
};
|
||||
|
||||
@@ -293,9 +301,9 @@ async function saveBaseModel(filePath, originalValue) {
|
||||
try {
|
||||
await getModelApiClient().saveModelMetadata(filePath, { base_model: newBaseModel });
|
||||
|
||||
showToast('Base model updated successfully', 'success');
|
||||
showToast('toast.models.baseModelUpdated', {}, 'success');
|
||||
} catch (error) {
|
||||
showToast('Failed to update base model', 'error');
|
||||
showToast('toast.models.baseModelUpdateFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,7 +389,7 @@ export function setupFileNameEditing(filePath) {
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
showToast('Invalid characters removed from filename', 'warning');
|
||||
showToast('toast.models.invalidCharactersRemoved', {}, 'warning');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -396,7 +404,7 @@ export function setupFileNameEditing(filePath) {
|
||||
if (!newFileName) {
|
||||
// Restore original value if empty
|
||||
this.textContent = originalValue;
|
||||
showToast('File name cannot be empty', 'error');
|
||||
showToast('toast.models.filenameCannotBeEmpty', {}, 'error');
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
@@ -415,7 +423,7 @@ export function setupFileNameEditing(filePath) {
|
||||
} catch (error) {
|
||||
console.error('Error renaming file:', error);
|
||||
this.textContent = originalValue; // Restore original file name
|
||||
showToast(`Failed to rename file: ${error.message}`, 'error');
|
||||
showToast('toast.models.renameFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
exitEditMode();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
scrollToTop,
|
||||
loadExampleImages
|
||||
} from './showcase/ShowcaseView.js';
|
||||
import { setupTabSwitching, setupModelDescriptionEditing } from './ModelDescription.js';
|
||||
import { setupTabSwitching } from './ModelDescription.js';
|
||||
import {
|
||||
setupModelNameEditing,
|
||||
setupBaseModelEditing,
|
||||
@@ -18,74 +18,100 @@ import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
|
||||
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
||||
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
||||
import { loadRecipesForLora } from './RecipeTab.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
|
||||
/**
|
||||
* Display the model modal with the given model data
|
||||
* @param {Object} model - Model data object
|
||||
* @param {string} modelType - Type of model ('lora' or 'checkpoint')
|
||||
*/
|
||||
export function showModelModal(model, modelType) {
|
||||
export async function showModelModal(model, modelType) {
|
||||
const modalId = 'modelModal';
|
||||
const modalTitle = model.model_name;
|
||||
|
||||
// Fetch complete civitai metadata
|
||||
let completeCivitaiData = model.civitai || {};
|
||||
if (model.file_path) {
|
||||
try {
|
||||
const fullMetadata = await getModelApiClient().fetchModelMetadata(model.file_path);
|
||||
completeCivitaiData = fullMetadata || model.civitai || {};
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch complete metadata, using existing data:', error);
|
||||
// Continue with existing data if fetch fails
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare LoRA specific data
|
||||
const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && model.civitai?.trainedWords?.length ?
|
||||
model.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
|
||||
// Update model with complete civitai data
|
||||
const modelWithFullData = {
|
||||
...model,
|
||||
civitai: completeCivitaiData
|
||||
};
|
||||
|
||||
// Prepare LoRA specific data with complete civitai data
|
||||
const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ?
|
||||
modelWithFullData.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
|
||||
|
||||
// Generate model type specific content
|
||||
let typeSpecificContent;
|
||||
if (modelType === 'loras') {
|
||||
typeSpecificContent = renderLoraSpecificContent(model, escapedWords);
|
||||
typeSpecificContent = renderLoraSpecificContent(modelWithFullData, escapedWords);
|
||||
} else if (modelType === 'embeddings') {
|
||||
typeSpecificContent = renderEmbeddingSpecificContent(model, escapedWords);
|
||||
typeSpecificContent = renderEmbeddingSpecificContent(modelWithFullData, escapedWords);
|
||||
} else {
|
||||
typeSpecificContent = '';
|
||||
}
|
||||
|
||||
|
||||
// Generate tabs based on model type
|
||||
const examplesText = translate('modals.model.tabs.examples', {}, 'Examples');
|
||||
const descriptionText = translate('modals.model.tabs.description', {}, 'Model Description');
|
||||
const recipesText = translate('modals.model.tabs.recipes', {}, 'Recipes');
|
||||
|
||||
const tabsContent = modelType === 'loras' ?
|
||||
`<button class="tab-btn active" data-tab="showcase">Examples</button>
|
||||
<button class="tab-btn" data-tab="description">Model Description</button>
|
||||
<button class="tab-btn" data-tab="recipes">Recipes</button>` :
|
||||
`<button class="tab-btn active" data-tab="showcase">Examples</button>
|
||||
<button class="tab-btn" data-tab="description">Model Description</button>`;
|
||||
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
|
||||
<button class="tab-btn" data-tab="description">${descriptionText}</button>
|
||||
<button class="tab-btn" data-tab="recipes">${recipesText}</button>` :
|
||||
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
|
||||
<button class="tab-btn" data-tab="description">${descriptionText}</button>`;
|
||||
|
||||
const loadingExampleImagesText = translate('modals.model.loading.exampleImages', {}, 'Loading example images...');
|
||||
const loadingDescriptionText = translate('modals.model.loading.description', {}, 'Loading model description...');
|
||||
const loadingRecipesText = translate('modals.model.loading.recipes', {}, 'Loading recipes...');
|
||||
const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...');
|
||||
|
||||
const tabPanesContent = modelType === 'loras' ?
|
||||
`<div id="showcase-tab" class="tab-pane active">
|
||||
<div class="example-images-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading example images...
|
||||
<i class="fas fa-spinner fa-spin"></i> ${loadingExampleImagesText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="description-tab" class="tab-pane">
|
||||
<div class="model-description-container">
|
||||
<div class="model-description-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading model description...
|
||||
<i class="fas fa-spinner fa-spin"></i> ${loadingDescriptionText}
|
||||
</div>
|
||||
<div class="model-description-content">
|
||||
${model.modelDescription || ''}
|
||||
<div class="model-description-content hidden">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="recipes-tab" class="tab-pane">
|
||||
<div class="recipes-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading recipes...
|
||||
<i class="fas fa-spinner fa-spin"></i> ${loadingRecipesText}
|
||||
</div>
|
||||
</div>` :
|
||||
`<div id="showcase-tab" class="tab-pane active">
|
||||
<div class="recipes-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading examples...
|
||||
<i class="fas fa-spinner fa-spin"></i> ${loadingExamplesText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="description-tab" class="tab-pane">
|
||||
<div class="model-description-container">
|
||||
<div class="model-description-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading model description...
|
||||
<i class="fas fa-spinner fa-spin"></i> ${loadingDescriptionText}
|
||||
</div>
|
||||
<div class="model-description-content">
|
||||
${model.modelDescription || ''}
|
||||
<div class="model-description-content hidden">
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -96,86 +122,86 @@ export function showModelModal(model, modelType) {
|
||||
<header class="modal-header">
|
||||
<div class="model-name-header">
|
||||
<h2 class="model-name-content">${modalTitle}</h2>
|
||||
<button class="edit-model-name-btn" title="Edit model name">
|
||||
<button class="edit-model-name-btn" title="${translate('modals.model.actions.editModelName', {}, 'Edit model name')}">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="creator-actions">
|
||||
${model.from_civitai ? `
|
||||
<div class="civitai-view" title="View on Civitai" data-action="view-civitai" data-filepath="${model.file_path}">
|
||||
<i class="fas fa-globe"></i> View on Civitai
|
||||
${modelWithFullData.from_civitai ? `
|
||||
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${modelWithFullData.file_path}">
|
||||
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
|
||||
</div>` : ''}
|
||||
|
||||
${model.civitai?.creator ? `
|
||||
<div class="creator-info" data-username="${model.civitai.creator.username}" data-action="view-creator" title="View Creator Profile">
|
||||
${model.civitai.creator.image ?
|
||||
${modelWithFullData.civitai?.creator ? `
|
||||
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
|
||||
${modelWithFullData.civitai.creator.image ?
|
||||
`<div class="creator-avatar">
|
||||
<img src="${model.civitai.creator.image}" alt="${model.civitai.creator.username}" onerror="this.onerror=null; this.src='static/icons/user-placeholder.png';">
|
||||
<img src="${modelWithFullData.civitai.creator.image}" alt="${modelWithFullData.civitai.creator.username}" onerror="this.onerror=null; this.src='static/icons/user-placeholder.png';">
|
||||
</div>` :
|
||||
`<div class="creator-avatar creator-placeholder">
|
||||
<i class="fas fa-user"></i>
|
||||
</div>`
|
||||
}
|
||||
<span class="creator-username">${model.civitai.creator.username}</span>
|
||||
<span class="creator-username">${modelWithFullData.civitai.creator.username}</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
${renderCompactTags(model.tags || [], model.file_path)}
|
||||
${renderCompactTags(modelWithFullData.tags || [], modelWithFullData.file_path)}
|
||||
</header>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="info-section">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>Version</label>
|
||||
<span>${model.civitai?.name || 'N/A'}</span>
|
||||
<label>${translate('modals.model.metadata.version', {}, 'Version')}</label>
|
||||
<span>${modelWithFullData.civitai?.name || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>File Name</label>
|
||||
<label>${translate('modals.model.metadata.fileName', {}, 'File Name')}</label>
|
||||
<div class="file-name-wrapper">
|
||||
<span id="file-name" class="file-name-content">${model.file_name || 'N/A'}</span>
|
||||
<button class="edit-file-name-btn" title="Edit file name">
|
||||
<span id="file-name" class="file-name-content">${modelWithFullData.file_name || 'N/A'}</span>
|
||||
<button class="edit-file-name-btn" title="${translate('modals.model.actions.editFileName', {}, 'Edit file name')}">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item location-size">
|
||||
<div class="location-wrapper">
|
||||
<label>Location</label>
|
||||
<span class="file-path">${model.file_path.replace(/[^/]+$/, '') || 'N/A'}</span>
|
||||
<label>${translate('modals.model.metadata.location', {}, 'Location')}</label>
|
||||
<span class="file-path">${modelWithFullData.file_path.replace(/[^/]+$/, '') || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item base-size">
|
||||
<div class="base-wrapper">
|
||||
<label>Base Model</label>
|
||||
<label>${translate('modals.model.metadata.baseModel', {}, 'Base Model')}</label>
|
||||
<div class="base-model-display">
|
||||
<span class="base-model-content">${model.base_model || 'Unknown'}</span>
|
||||
<button class="edit-base-model-btn" title="Edit base model">
|
||||
<span class="base-model-content">${modelWithFullData.base_model || translate('modals.model.metadata.unknown', {}, 'Unknown')}</span>
|
||||
<button class="edit-base-model-btn" title="${translate('modals.model.actions.editBaseModel', {}, 'Edit base model')}">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="size-wrapper">
|
||||
<label>Size</label>
|
||||
<span>${formatFileSize(model.file_size)}</span>
|
||||
<label>${translate('modals.model.metadata.size', {}, 'Size')}</label>
|
||||
<span>${formatFileSize(modelWithFullData.file_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
${typeSpecificContent}
|
||||
<div class="info-item notes">
|
||||
<label>Additional Notes <i class="fas fa-info-circle notes-hint" title="Press Enter to save, Shift+Enter for new line"></i></label>
|
||||
<label>${translate('modals.model.metadata.additionalNotes', {}, 'Additional Notes')} <i class="fas fa-info-circle notes-hint" title="${translate('modals.model.metadata.notesHint', {}, 'Press Enter to save, Shift+Enter for new line')}"></i></label>
|
||||
<div class="editable-field">
|
||||
<div class="notes-content" contenteditable="true" spellcheck="false">${model.notes || 'Add your notes here...'}</div>
|
||||
<div class="notes-content" contenteditable="true" spellcheck="false">${modelWithFullData.notes || translate('modals.model.metadata.addNotesPlaceholder', {}, 'Add your notes here...')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item full-width">
|
||||
<label>About this version</label>
|
||||
<div class="description-text">${model.civitai?.description || 'N/A'}</div>
|
||||
<label>${translate('modals.model.metadata.aboutThisVersion', {}, 'About this version')}</label>
|
||||
<div class="description-text">${modelWithFullData.civitai?.description || 'N/A'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="showcase-section" data-model-hash="${model.sha256 || ''}" data-filepath="${model.file_path}">
|
||||
<div class="showcase-section" data-model-hash="${modelWithFullData.sha256 || ''}" data-filepath="${modelWithFullData.file_path}">
|
||||
<div class="showcase-tabs">
|
||||
${tabsContent}
|
||||
</div>
|
||||
@@ -202,16 +228,15 @@ export function showModelModal(model, modelType) {
|
||||
};
|
||||
|
||||
modalManager.showModal(modalId, content, null, onCloseCallback);
|
||||
setupEditableFields(model.file_path, modelType);
|
||||
setupEditableFields(modelWithFullData.file_path, modelType);
|
||||
setupShowcaseScroll(modalId);
|
||||
setupTabSwitching();
|
||||
setupTagTooltip();
|
||||
setupTagEditMode();
|
||||
setupModelNameEditing(model.file_path);
|
||||
setupBaseModelEditing(model.file_path);
|
||||
setupFileNameEditing(model.file_path);
|
||||
setupModelDescriptionEditing(model.file_path, model.modelDescription || '');
|
||||
setupEventHandlers(model.file_path);
|
||||
setupModelNameEditing(modelWithFullData.file_path);
|
||||
setupBaseModelEditing(modelWithFullData.file_path);
|
||||
setupFileNameEditing(modelWithFullData.file_path);
|
||||
setupEventHandlers(modelWithFullData.file_path);
|
||||
|
||||
// LoRA specific setup
|
||||
if (modelType === 'loras' || modelType === 'embeddings') {
|
||||
@@ -219,33 +244,33 @@ export function showModelModal(model, modelType) {
|
||||
|
||||
if (modelType == 'loras') {
|
||||
// Load recipes for this LoRA
|
||||
loadRecipesForLora(model.model_name, model.sha256);
|
||||
loadRecipesForLora(modelWithFullData.model_name, modelWithFullData.sha256);
|
||||
}
|
||||
}
|
||||
|
||||
// Load example images asynchronously - merge regular and custom images
|
||||
const regularImages = model.civitai?.images || [];
|
||||
const customImages = model.civitai?.customImages || [];
|
||||
const regularImages = modelWithFullData.civitai?.images || [];
|
||||
const customImages = modelWithFullData.civitai?.customImages || [];
|
||||
// Combine images - regular images first, then custom images
|
||||
const allImages = [...regularImages, ...customImages];
|
||||
loadExampleImages(allImages, model.sha256);
|
||||
loadExampleImages(allImages, modelWithFullData.sha256);
|
||||
}
|
||||
|
||||
function renderLoraSpecificContent(lora, escapedWords) {
|
||||
return `
|
||||
<div class="info-item usage-tips">
|
||||
<label>Usage Tips</label>
|
||||
<label>${translate('modals.model.metadata.usageTips', {}, 'Usage Tips')}</label>
|
||||
<div class="editable-field">
|
||||
<div class="preset-controls">
|
||||
<select id="preset-selector">
|
||||
<option value="">Add preset parameter...</option>
|
||||
<option value="strength_min">Strength Min</option>
|
||||
<option value="strength_max">Strength Max</option>
|
||||
<option value="strength">Strength</option>
|
||||
<option value="clip_skip">Clip Skip</option>
|
||||
<option value="">${translate('modals.model.usageTips.addPresetParameter', {}, 'Add preset parameter...')}</option>
|
||||
<option value="strength_min">${translate('modals.model.usageTips.strengthMin', {}, 'Strength Min')}</option>
|
||||
<option value="strength_max">${translate('modals.model.usageTips.strengthMax', {}, 'Strength Max')}</option>
|
||||
<option value="strength">${translate('modals.model.usageTips.strength', {}, 'Strength')}</option>
|
||||
<option value="clip_skip">${translate('modals.model.usageTips.clipSkip', {}, 'Clip Skip')}</option>
|
||||
</select>
|
||||
<input type="number" id="preset-value" step="0.01" placeholder="Value" style="display:none;">
|
||||
<button class="add-preset-btn">Add</button>
|
||||
<input type="number" id="preset-value" step="0.01" placeholder="${translate('modals.model.usageTips.valuePlaceholder', {}, 'Value')}" style="display:none;">
|
||||
<button class="add-preset-btn">${translate('modals.model.usageTips.add', {}, 'Add')}</button>
|
||||
</div>
|
||||
<div class="preset-tags">
|
||||
${renderPresetTags(parsePresets(lora.usage_tips))}
|
||||
@@ -413,9 +438,9 @@ async function saveNotes(filePath) {
|
||||
try {
|
||||
await getModelApiClient().saveModelMetadata(filePath, { notes: content });
|
||||
|
||||
showToast('Notes saved successfully', 'success');
|
||||
showToast('modals.model.notes.saved', {}, 'success');
|
||||
} catch (error) {
|
||||
showToast('Failed to save notes', 'error');
|
||||
showToast('modals.model.notes.saveFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
|
||||
// Preset tag suggestions
|
||||
const PRESET_TAGS = [
|
||||
@@ -216,10 +217,10 @@ async function saveTags() {
|
||||
// Exit edit mode
|
||||
editBtn.click();
|
||||
|
||||
showToast('Tags updated successfully', 'success');
|
||||
showToast('modelTags.messages.updated', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error saving tags:', error);
|
||||
showToast('Failed to update tags', 'error');
|
||||
showToast('modelTags.messages.updateFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,21 +362,21 @@ function addNewTag(tag) {
|
||||
|
||||
// Validation: Check length
|
||||
if (tag.length > 30) {
|
||||
showToast('Tag should not exceed 30 characters', 'error');
|
||||
showToast('modelTags.validation.maxLength', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation: Check total number
|
||||
const currentTags = tagsContainer.querySelectorAll('.metadata-item');
|
||||
if (currentTags.length >= 30) {
|
||||
showToast('Maximum 30 tags allowed', 'error');
|
||||
showToast('modelTags.validation.maxCount', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation: Check for duplicates
|
||||
const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag);
|
||||
if (existingTags.includes(tag)) {
|
||||
showToast('This tag already exists', 'error');
|
||||
showToast('modelTags.validation.duplicate', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ function getLoraStatusTitle(totalCount, missingCount) {
|
||||
*/
|
||||
function copyRecipeSyntax(recipeId) {
|
||||
if (!recipeId) {
|
||||
showToast('Cannot copy recipe syntax: Missing recipe ID', 'error');
|
||||
showToast('toast.recipes.noRecipeId', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ function copyRecipeSyntax(recipeId) {
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
showToast('Failed to copy recipe syntax', 'error');
|
||||
showToast('toast.recipes.copyFailed', { message: err.message }, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Moved to shared directory for consistency
|
||||
*/
|
||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
|
||||
/**
|
||||
@@ -26,7 +27,7 @@ async function fetchTrainedWords(filePath) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching trained words:', error);
|
||||
showToast('Could not load trained words', 'error');
|
||||
showToast('toast.triggerWords.loadFailed', {}, 'error');
|
||||
return { trainedWords: [], classTokens: null };
|
||||
}
|
||||
}
|
||||
@@ -48,9 +49,9 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
||||
|
||||
// No suggestions case
|
||||
if ((!trainedWords || trainedWords.length === 0) && !classTokens) {
|
||||
header.innerHTML = '<span>No suggestions available</span>';
|
||||
header.innerHTML = `<span>${translate('modals.model.triggerWords.suggestions.noSuggestions')}</span>`;
|
||||
dropdown.appendChild(header);
|
||||
dropdown.innerHTML += '<div class="no-suggestions">No trained words or class tokens found in this model. You can manually enter trigger words.</div>';
|
||||
dropdown.innerHTML += `<div class="no-suggestions">${translate('modals.model.triggerWords.suggestions.noTrainedWords')}</div>`;
|
||||
return dropdown;
|
||||
}
|
||||
|
||||
@@ -65,8 +66,8 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
||||
const classTokensHeader = document.createElement('div');
|
||||
classTokensHeader.className = 'metadata-suggestions-header';
|
||||
classTokensHeader.innerHTML = `
|
||||
<span>Class Token</span>
|
||||
<small>Add to your prompt for best results</small>
|
||||
<span>${translate('modals.model.triggerWords.suggestions.classToken')}</span>
|
||||
<small>${translate('modals.model.triggerWords.suggestions.classTokenDescription')}</small>
|
||||
`;
|
||||
dropdown.appendChild(classTokensHeader);
|
||||
|
||||
@@ -77,13 +78,13 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
||||
// Create a special item for the class token
|
||||
const tokenItem = document.createElement('div');
|
||||
tokenItem.className = `metadata-suggestion-item class-token-item ${existingWords.includes(classTokens) ? 'already-added' : ''}`;
|
||||
tokenItem.title = `Class token: ${classTokens}`;
|
||||
tokenItem.title = `${translate('modals.model.triggerWords.suggestions.classToken')}: ${classTokens}`;
|
||||
tokenItem.innerHTML = `
|
||||
<span class="metadata-suggestion-text">${classTokens}</span>
|
||||
<div class="metadata-suggestion-meta">
|
||||
<span class="token-badge">Class Token</span>
|
||||
<span class="token-badge">${translate('modals.model.triggerWords.suggestions.classToken')}</span>
|
||||
${existingWords.includes(classTokens) ?
|
||||
'<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
|
||||
`<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -119,8 +120,8 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
||||
// Add trained words header if we have any
|
||||
if (trainedWords && trainedWords.length > 0) {
|
||||
header.innerHTML = `
|
||||
<span>Word Suggestions</span>
|
||||
<small>${trainedWords.length} words found</small>
|
||||
<span>${translate('modals.model.triggerWords.suggestions.wordSuggestions')}</span>
|
||||
<small>${translate('modals.model.triggerWords.suggestions.wordsFound', { count: trainedWords.length })}</small>
|
||||
`;
|
||||
dropdown.appendChild(header);
|
||||
|
||||
@@ -139,7 +140,7 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
||||
<span class="metadata-suggestion-text">${word}</span>
|
||||
<div class="metadata-suggestion-meta">
|
||||
<span class="trained-word-freq">${frequency}</span>
|
||||
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
|
||||
${isAdded ? `<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -166,7 +167,7 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
||||
dropdown.appendChild(container);
|
||||
} else if (!classTokens) {
|
||||
// If we have neither class tokens nor trained words
|
||||
dropdown.innerHTML += '<div class="no-suggestions">No word suggestions found in this model. You can manually enter trigger words.</div>';
|
||||
dropdown.innerHTML += `<div class="no-suggestions">${translate('modals.model.triggerWords.suggestions.noTrainedWords')}</div>`;
|
||||
}
|
||||
|
||||
return dropdown;
|
||||
@@ -182,22 +183,22 @@ export function renderTriggerWords(words, filePath) {
|
||||
if (!words.length) return `
|
||||
<div class="info-item full-width trigger-words">
|
||||
<div class="trigger-words-header">
|
||||
<label>Trigger Words</label>
|
||||
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="Edit trigger words">
|
||||
<label>${translate('modals.model.triggerWords.label')}</label>
|
||||
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="${translate('modals.model.triggerWords.edit')}">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="trigger-words-content">
|
||||
<span class="no-trigger-words">No trigger word needed</span>
|
||||
<span class="no-trigger-words">${translate('modals.model.triggerWords.noTriggerWordsNeeded')}</span>
|
||||
<div class="trigger-words-tags" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="metadata-edit-controls" style="display:none;">
|
||||
<button class="metadata-save-btn" title="Save changes">
|
||||
<i class="fas fa-save"></i> Save
|
||||
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
|
||||
<i class="fas fa-save"></i> ${translate('common.actions.save')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="metadata-add-form" style="display:none;">
|
||||
<input type="text" class="metadata-input" placeholder="Type to add or click suggestions below">
|
||||
<input type="text" class="metadata-input" placeholder="${translate('modals.model.triggerWords.addPlaceholder')}">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -205,20 +206,20 @@ export function renderTriggerWords(words, filePath) {
|
||||
return `
|
||||
<div class="info-item full-width trigger-words">
|
||||
<div class="trigger-words-header">
|
||||
<label>Trigger Words</label>
|
||||
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="Edit trigger words">
|
||||
<label>${translate('modals.model.triggerWords.label')}</label>
|
||||
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="${translate('modals.model.triggerWords.edit')}">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="trigger-words-content">
|
||||
<div class="trigger-words-tags">
|
||||
${words.map(word => `
|
||||
<div class="trigger-word-tag" data-word="${word}" onclick="copyTriggerWord('${word}')">
|
||||
<div class="trigger-word-tag" data-word="${word}" onclick="copyTriggerWord('${word}')" title="${translate('modals.model.triggerWords.copyWord')}">
|
||||
<span class="trigger-word-content">${word}</span>
|
||||
<span class="trigger-word-copy">
|
||||
<i class="fas fa-copy"></i>
|
||||
</span>
|
||||
<button class="metadata-delete-btn" style="display:none;" onclick="event.stopPropagation();">
|
||||
<button class="metadata-delete-btn" style="display:none;" onclick="event.stopPropagation();" title="${translate('modals.model.triggerWords.deleteWord')}">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -226,12 +227,12 @@ export function renderTriggerWords(words, filePath) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="metadata-edit-controls" style="display:none;">
|
||||
<button class="metadata-save-btn" title="Save changes">
|
||||
<i class="fas fa-save"></i> Save
|
||||
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
|
||||
<i class="fas fa-save"></i> ${translate('common.actions.save')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="metadata-add-form" style="display:none;">
|
||||
<input type="text" class="metadata-input" placeholder="Type to add or click suggestions below">
|
||||
<input type="text" class="metadata-input" placeholder="${translate('modals.model.triggerWords.addPlaceholder')}">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -265,7 +266,7 @@ export function setupTriggerWordsEditMode() {
|
||||
|
||||
if (isEditMode) {
|
||||
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
|
||||
this.title = "Cancel editing";
|
||||
this.title = translate('modals.model.triggerWords.cancel');
|
||||
|
||||
// Store original trigger words for potential restoration
|
||||
originalTriggerWords = Array.from(triggerWordTags).map(tag => tag.dataset.word);
|
||||
@@ -302,7 +303,7 @@ export function setupTriggerWordsEditMode() {
|
||||
// Add loading indicator
|
||||
const loadingIndicator = document.createElement('div');
|
||||
loadingIndicator.className = 'metadata-loading';
|
||||
loadingIndicator.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading suggestions...';
|
||||
loadingIndicator.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${translate('modals.model.triggerWords.suggestions.loading')}`;
|
||||
addForm.appendChild(loadingIndicator);
|
||||
|
||||
// Get currently added trigger words
|
||||
@@ -329,7 +330,7 @@ export function setupTriggerWordsEditMode() {
|
||||
|
||||
} else {
|
||||
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
|
||||
this.title = "Edit trigger words";
|
||||
this.title = translate('modals.model.triggerWords.edit');
|
||||
|
||||
// Hide edit controls and input form
|
||||
editControls.style.display = 'none';
|
||||
@@ -499,21 +500,21 @@ function addNewTriggerWord(word) {
|
||||
|
||||
// Validation: Check length
|
||||
if (word.split(/\s+/).length > 30) {
|
||||
showToast('Trigger word should not exceed 30 words', 'error');
|
||||
showToast('toast.triggerWords.tooLong', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation: Check total number
|
||||
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
|
||||
if (currentTags.length >= 30) {
|
||||
showToast('Maximum 30 trigger words allowed', 'error');
|
||||
showToast('toast.triggerWords.tooMany', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation: Check for duplicates
|
||||
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
|
||||
if (existingWords.includes(word)) {
|
||||
showToast('This trigger word already exists', 'error');
|
||||
showToast('toast.triggerWords.alreadyExists', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -628,10 +629,10 @@ async function saveTriggerWords() {
|
||||
if (tagsContainer) tagsContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
showToast('Trigger words updated successfully', 'success');
|
||||
showToast('toast.triggerWords.updateSuccess', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error saving trigger words:', error);
|
||||
showToast('Failed to update trigger words', 'error');
|
||||
showToast('toast.triggerWords.updateFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,6 +645,6 @@ window.copyTriggerWord = async function(word) {
|
||||
await copyToClipboard(word, 'Trigger word copied');
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
showToast('Copy failed', 'error');
|
||||
showToast('toast.triggerWords.copyFailed', {}, 'error');
|
||||
}
|
||||
};
|
||||
@@ -278,7 +278,7 @@ export function initMetadataPanelHandlers(container) {
|
||||
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
showToast('Copy failed', 'error');
|
||||
showToast('toast.triggerWords.copyFailed', {}, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -432,7 +432,7 @@ export function initMediaControlHandlers(container) {
|
||||
}, 600);
|
||||
|
||||
// Show success toast
|
||||
showToast('Example image deleted', 'success');
|
||||
showToast('toast.exampleImages.deleted', {}, 'success');
|
||||
|
||||
// Create an update object with only the necessary properties
|
||||
const updateData = {
|
||||
@@ -445,7 +445,7 @@ export function initMediaControlHandlers(container) {
|
||||
state.virtualScroller.updateSingleItem(result.model_file_path, updateData);
|
||||
} else {
|
||||
// Show error message
|
||||
showToast(result.error || 'Failed to delete example image', 'error');
|
||||
showToast('toast.exampleImages.deleteFailed', { error: result.error }, 'error');
|
||||
|
||||
// Reset button state
|
||||
this.disabled = false;
|
||||
@@ -456,7 +456,7 @@ export function initMediaControlHandlers(container) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting example image:', error);
|
||||
showToast('Failed to delete example image', 'error');
|
||||
showToast('toast.exampleImages.deleteFailed', {}, 'error');
|
||||
|
||||
// Reset button state
|
||||
this.disabled = false;
|
||||
@@ -536,7 +536,7 @@ function initSetPreviewHandlers(container) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting preview:', error);
|
||||
showToast('Failed to set preview image', 'error');
|
||||
showToast('toast.exampleImages.setPreviewFailed', {}, 'error');
|
||||
} finally {
|
||||
// Restore button state
|
||||
this.innerHTML = '<i class="fas fa-image"></i>';
|
||||
|
||||
@@ -412,7 +412,7 @@ async function handleImportFiles(files, modelHash, importContainer) {
|
||||
// Initialize the import UI for the new content
|
||||
initExampleImport(modelHash, showcaseTab);
|
||||
|
||||
showToast('Example images imported successfully', 'success');
|
||||
showToast('toast.import.imagesImported', {}, 'success');
|
||||
|
||||
// Update VirtualScroller if available
|
||||
if (state.virtualScroller && result.model_file_path) {
|
||||
@@ -430,7 +430,7 @@ async function handleImportFiles(files, modelHash, importContainer) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error importing examples:', error);
|
||||
showToast(`Failed to import example images: ${error.message}`, 'error');
|
||||
showToast('toast.import.importFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,11 @@ import { bulkManager } from './managers/BulkManager.js';
|
||||
import { exampleImagesManager } from './managers/ExampleImagesManager.js';
|
||||
import { helpManager } from './managers/HelpManager.js';
|
||||
import { bannerService } from './managers/BannerService.js';
|
||||
import { showToast, initTheme, initBackToTop } from './utils/uiHelpers.js';
|
||||
import { initTheme, initBackToTop } from './utils/uiHelpers.js';
|
||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
||||
import { migrateStorageItems } from './utils/storageHelpers.js';
|
||||
import { i18n } from './i18n/index.js';
|
||||
import { onboardingManager } from './managers/OnboardingManager.js';
|
||||
|
||||
// Core application class
|
||||
export class AppCore {
|
||||
@@ -26,6 +28,13 @@ export class AppCore {
|
||||
|
||||
console.log('AppCore: Initializing...');
|
||||
|
||||
// Initialize i18n first
|
||||
window.i18n = i18n;
|
||||
// Wait for i18n to be ready
|
||||
await window.i18n.waitForReady();
|
||||
|
||||
console.log(`AppCore: Language set: ${i18n.getCurrentLocale()}`);
|
||||
|
||||
// Initialize managers
|
||||
state.loadingManager = new LoadingManager();
|
||||
modalManager.initialize();
|
||||
@@ -57,6 +66,14 @@ export class AppCore {
|
||||
// Mark as initialized
|
||||
this.initialized = true;
|
||||
|
||||
// Start onboarding if needed (after everything is initialized)
|
||||
setTimeout(() => {
|
||||
// Do not show onboarding if version-mismatch banner is visible
|
||||
if (!bannerService.isBannerVisible('version-mismatch')) {
|
||||
onboardingManager.start();
|
||||
}
|
||||
}, 1000); // Small delay to ensure all elements are rendered
|
||||
|
||||
// Return the core instance for chaining
|
||||
return this;
|
||||
}
|
||||
@@ -67,11 +84,6 @@ export class AppCore {
|
||||
return body.dataset.page || 'unknown';
|
||||
}
|
||||
|
||||
// Show toast messages
|
||||
showToast(message, type = 'info') {
|
||||
showToast(message, type);
|
||||
}
|
||||
|
||||
// Initialize common UI features based on page type
|
||||
initializePageFeatures() {
|
||||
const pageType = this.getPageType();
|
||||
|
||||
@@ -30,10 +30,6 @@ class EmbeddingsPageManager {
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
// Initialize page-specific components
|
||||
this.pageControls.restoreFolderFilter();
|
||||
this.pageControls.initFolderTagsVisibility();
|
||||
|
||||
// Initialize context menu
|
||||
new EmbeddingContextMenu();
|
||||
|
||||
|
||||
341
static/js/i18n/index.js
Normal file
341
static/js/i18n/index.js
Normal file
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Internationalization (i18n) system for LoRA Manager
|
||||
* Uses user-selected language from settings with fallback to English
|
||||
* Loads JSON translation files dynamically
|
||||
*/
|
||||
|
||||
class I18nManager {
|
||||
constructor() {
|
||||
this.locales = {};
|
||||
this.translations = {};
|
||||
this.loadedLocales = new Set();
|
||||
this.ready = false;
|
||||
this.readyPromise = null;
|
||||
|
||||
// Available locales configuration
|
||||
this.availableLocales = {
|
||||
'en': { name: 'English', nativeName: 'English' },
|
||||
'zh-CN': { name: 'Chinese (Simplified)', nativeName: '简体中文' },
|
||||
'zh-TW': { name: 'Chinese (Traditional)', nativeName: '繁體中文' },
|
||||
'zh': { name: 'Chinese (Simplified)', nativeName: '简体中文' }, // Fallback to zh-CN
|
||||
'ru': { name: 'Russian', nativeName: 'Русский' },
|
||||
'de': { name: 'German', nativeName: 'Deutsch' },
|
||||
'ja': { name: 'Japanese', nativeName: '日本語' },
|
||||
'ko': { name: 'Korean', nativeName: '한국어' },
|
||||
'fr': { name: 'French', nativeName: 'Français' },
|
||||
'es': { name: 'Spanish', nativeName: 'Español' }
|
||||
};
|
||||
|
||||
this.currentLocale = this.getLanguageFromSettings();
|
||||
|
||||
// Initialize with current locale and create ready promise
|
||||
this.readyPromise = this.initializeWithLocale(this.currentLocale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load translations for a specific locale from JSON file
|
||||
* @param {string} locale - The locale to load
|
||||
* @returns {Promise<Object>} Promise that resolves to the translation data
|
||||
*/
|
||||
async loadLocale(locale) {
|
||||
// Handle fallback for 'zh' to 'zh-CN'
|
||||
const normalizedLocale = locale === 'zh' ? 'zh-CN' : locale;
|
||||
|
||||
if (this.loadedLocales.has(normalizedLocale)) {
|
||||
return this.locales[normalizedLocale];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/locales/${normalizedLocale}.json`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const translations = await response.json();
|
||||
this.locales[normalizedLocale] = translations;
|
||||
this.loadedLocales.add(normalizedLocale);
|
||||
|
||||
// Also set for 'zh' alias
|
||||
if (normalizedLocale === 'zh-CN') {
|
||||
this.locales['zh'] = translations;
|
||||
this.loadedLocales.add('zh');
|
||||
}
|
||||
|
||||
return translations;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load locale ${normalizedLocale}:`, error);
|
||||
// Fallback to English if current locale fails and it's not English
|
||||
if (normalizedLocale !== 'en') {
|
||||
return this.loadLocale('en');
|
||||
}
|
||||
// Return empty object if even English fails
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize with a specific locale
|
||||
* @param {string} locale - The locale to initialize with
|
||||
*/
|
||||
async initializeWithLocale(locale) {
|
||||
try {
|
||||
this.translations = await this.loadLocale(locale);
|
||||
this.currentLocale = locale;
|
||||
this.ready = true;
|
||||
|
||||
// Dispatch ready event
|
||||
window.dispatchEvent(new CustomEvent('i18nReady', {
|
||||
detail: { language: locale }
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to initialize with locale ${locale}, falling back to English`, error);
|
||||
this.translations = await this.loadLocale('en');
|
||||
this.currentLocale = 'en';
|
||||
this.ready = true;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('i18nReady', {
|
||||
detail: { language: 'en' }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for i18n to be ready
|
||||
* @returns {Promise} Promise that resolves when i18n is ready
|
||||
*/
|
||||
async waitForReady() {
|
||||
if (this.ready) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this.readyPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if i18n is ready
|
||||
* @returns {boolean} True if ready
|
||||
*/
|
||||
isReady() {
|
||||
return this.ready && this.translations && Object.keys(this.translations).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get language from user settings with fallback to English
|
||||
* @returns {string} Language code
|
||||
*/
|
||||
getLanguageFromSettings() {
|
||||
// Check localStorage for user-selected language
|
||||
const STORAGE_PREFIX = 'lora_manager_';
|
||||
let userLanguage = null;
|
||||
|
||||
try {
|
||||
const settings = localStorage.getItem(STORAGE_PREFIX + 'settings');
|
||||
if (settings) {
|
||||
const parsedSettings = JSON.parse(settings);
|
||||
userLanguage = parsedSettings.language;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse settings from localStorage:', e);
|
||||
}
|
||||
|
||||
// If user has selected a language, use it
|
||||
if (userLanguage && this.availableLocales[userLanguage]) {
|
||||
return userLanguage;
|
||||
}
|
||||
|
||||
// Fallback to English
|
||||
return 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current language and save to settings
|
||||
* @param {string} languageCode - The language code to set
|
||||
* @returns {Promise<boolean>} True if language was successfully set
|
||||
*/
|
||||
async setLanguage(languageCode) {
|
||||
if (!this.availableLocales[languageCode]) {
|
||||
console.warn(`Language '${languageCode}' is not supported`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Reset ready state
|
||||
this.ready = false;
|
||||
|
||||
// Load the new locale
|
||||
this.readyPromise = this.initializeWithLocale(languageCode);
|
||||
await this.readyPromise;
|
||||
|
||||
// Save to localStorage
|
||||
const STORAGE_PREFIX = 'lora_manager_';
|
||||
const currentSettings = localStorage.getItem(STORAGE_PREFIX + 'settings');
|
||||
let settings = {};
|
||||
|
||||
if (currentSettings) {
|
||||
settings = JSON.parse(currentSettings);
|
||||
}
|
||||
|
||||
settings.language = languageCode;
|
||||
localStorage.setItem(STORAGE_PREFIX + 'settings', JSON.stringify(settings));
|
||||
|
||||
console.log(`Language changed to: ${languageCode}`);
|
||||
|
||||
// Dispatch event to notify components of language change
|
||||
window.dispatchEvent(new CustomEvent('languageChanged', {
|
||||
detail: { language: languageCode }
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to set language:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available languages with their native names
|
||||
* @returns {Array} Array of language objects
|
||||
*/
|
||||
getAvailableLanguages() {
|
||||
return Object.entries(this.availableLocales).map(([code, info]) => ({
|
||||
code,
|
||||
name: info.name,
|
||||
nativeName: info.nativeName
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translation for a key with optional parameters
|
||||
* @param {string} key - Translation key (supports dot notation)
|
||||
* @param {Object} params - Parameters for string interpolation
|
||||
* @returns {string} Translated text
|
||||
*/
|
||||
t(key, params = {}) {
|
||||
// If not ready, return key as fallback
|
||||
if (!this.isReady()) {
|
||||
console.warn(`i18n not ready, returning key: ${key}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
const keys = key.split('.');
|
||||
let value = this.translations;
|
||||
|
||||
// Navigate through nested object
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === 'object' && k in value) {
|
||||
value = value[k];
|
||||
} else {
|
||||
// Fallback to English if key not found in current locale
|
||||
if (this.currentLocale !== 'en' && this.locales['en']) {
|
||||
let fallbackValue = this.locales['en'];
|
||||
for (const fallbackKey of keys) {
|
||||
if (fallbackValue && typeof fallbackValue === 'object' && fallbackKey in fallbackValue) {
|
||||
fallbackValue = fallbackValue[fallbackKey];
|
||||
} else {
|
||||
console.warn(`Translation key not found: ${key}`);
|
||||
return key; // Return key as fallback
|
||||
}
|
||||
}
|
||||
value = fallbackValue;
|
||||
} else {
|
||||
console.warn(`Translation key not found: ${key}`);
|
||||
return key; // Return key as fallback
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
console.warn(`Translation key is not a string: ${key}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
// Replace parameters in the string
|
||||
return this.interpolate(value, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate parameters into a string
|
||||
* Supports both {{param}} and {param} syntax
|
||||
* @param {string} str - String with placeholders
|
||||
* @param {Object} params - Parameters to interpolate
|
||||
* @returns {string} Interpolated string
|
||||
*/
|
||||
interpolate(str, params) {
|
||||
return str.replace(/\{\{?(\w+)\}?\}/g, (match, key) => {
|
||||
return params[key] !== undefined ? params[key] : match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current locale
|
||||
* @returns {string} Current locale code
|
||||
*/
|
||||
getCurrentLocale() {
|
||||
return this.currentLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current locale is RTL (Right-to-Left)
|
||||
* @returns {boolean} True if RTL
|
||||
*/
|
||||
isRTL() {
|
||||
const rtlLocales = ['ar', 'he', 'fa', 'ur'];
|
||||
return rtlLocales.includes(this.currentLocale.split('-')[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number according to current locale
|
||||
* @param {number} number - Number to format
|
||||
* @param {Object} options - Intl.NumberFormat options
|
||||
* @returns {string} Formatted number
|
||||
*/
|
||||
formatNumber(number, options = {}) {
|
||||
return new Intl.NumberFormat(this.currentLocale, options).format(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date according to current locale
|
||||
* @param {Date|string|number} date - Date to format
|
||||
* @param {Object} options - Intl.DateTimeFormat options
|
||||
* @returns {string} Formatted date
|
||||
*/
|
||||
formatDate(date, options = {}) {
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
return new Intl.DateTimeFormat(this.currentLocale, options).format(dateObj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size with locale-specific formatting
|
||||
* @param {number} bytes - Size in bytes
|
||||
* @param {number} decimals - Number of decimal places
|
||||
* @returns {string} Formatted size
|
||||
*/
|
||||
formatFileSize(bytes, decimals = 2) {
|
||||
if (bytes === 0) return this.t('common.fileSize.zero');
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['bytes', 'kb', 'mb', 'gb', 'tb'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const size = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
|
||||
|
||||
return `${this.formatNumber(size)} ${this.t(`common.fileSize.${sizes[i]}`)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize i18n from user settings
|
||||
* This prevents language flashing on page load
|
||||
* @deprecated Use waitForReady() instead
|
||||
*/
|
||||
async initializeFromSettings() {
|
||||
console.warn('initializeFromSettings() is deprecated, use waitForReady() instead');
|
||||
return this.waitForReady();
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const i18n = new I18nManager();
|
||||
|
||||
// Export for global access (will be attached to window)
|
||||
export default i18n;
|
||||
@@ -38,8 +38,6 @@ class LoraPageManager {
|
||||
|
||||
async initialize() {
|
||||
// Initialize page-specific components
|
||||
this.pageControls.restoreFolderFilter();
|
||||
this.pageControls.initFolderTagsVisibility();
|
||||
new LoraContextMenu();
|
||||
|
||||
// Initialize cards for current bulk mode state (should be false initially)
|
||||
|
||||
@@ -171,6 +171,16 @@ class BannerService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a banner is currently rendered and visible
|
||||
* @param {string} bannerId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isBannerVisible(bannerId) {
|
||||
const el = document.querySelector(`[data-banner-id="${bannerId}"]`);
|
||||
return !!el && el.offsetParent !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update container visibility based on active banners
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ import { modalManager } from './ModalManager.js';
|
||||
import { moveManager } from './MoveManager.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||
import { updateElementText } from '../utils/i18nHelpers.js';
|
||||
|
||||
export class BulkManager {
|
||||
constructor() {
|
||||
@@ -182,12 +183,11 @@ export class BulkManager {
|
||||
|
||||
updateSelectedCount() {
|
||||
const countElement = document.getElementById('selectedCount');
|
||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
||||
const displayName = currentConfig?.displayName || 'Models';
|
||||
|
||||
if (countElement) {
|
||||
countElement.textContent = `${state.selectedModels.size} ${displayName.toLowerCase()}(s) selected `;
|
||||
|
||||
// Use i18nHelpers.js to update the count text
|
||||
updateElementText(countElement, 'loras.bulkOperations.selected', { count: state.selectedModels.size });
|
||||
|
||||
const existingCaret = countElement.querySelector('.dropdown-caret');
|
||||
if (existingCaret) {
|
||||
existingCaret.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
|
||||
@@ -283,12 +283,12 @@ export class BulkManager {
|
||||
|
||||
async copyAllModelsSyntax() {
|
||||
if (state.currentPageType !== MODEL_TYPES.LORA) {
|
||||
showToast('Copy syntax is only available for LoRAs', 'warning');
|
||||
showToast('toast.loras.copyOnlyForLoras', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('No LoRAs selected', 'warning');
|
||||
showToast('toast.loras.noLorasSelected', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -310,11 +310,11 @@ export class BulkManager {
|
||||
|
||||
if (missingLoras.length > 0) {
|
||||
console.warn('Missing metadata for some selected loras:', missingLoras);
|
||||
showToast(`Missing data for ${missingLoras.length} LoRAs`, 'warning');
|
||||
showToast('toast.loras.missingDataForLoras', { count: missingLoras.length }, 'warning');
|
||||
}
|
||||
|
||||
if (loraSyntaxes.length === 0) {
|
||||
showToast('No valid LoRAs to copy', 'error');
|
||||
showToast('toast.loras.noValidLorasToCopy', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -323,12 +323,12 @@ export class BulkManager {
|
||||
|
||||
async sendAllModelsToWorkflow() {
|
||||
if (state.currentPageType !== MODEL_TYPES.LORA) {
|
||||
showToast('Send to workflow is only available for LoRAs', 'warning');
|
||||
showToast('toast.loras.sendOnlyForLoras', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('No LoRAs selected', 'warning');
|
||||
showToast('toast.loras.noLorasSelected', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -350,11 +350,11 @@ export class BulkManager {
|
||||
|
||||
if (missingLoras.length > 0) {
|
||||
console.warn('Missing metadata for some selected loras:', missingLoras);
|
||||
showToast(`Missing data for ${missingLoras.length} LoRAs`, 'warning');
|
||||
showToast('toast.loras.missingDataForLoras', { count: missingLoras.length }, 'warning');
|
||||
}
|
||||
|
||||
if (loraSyntaxes.length === 0) {
|
||||
showToast('No valid LoRAs to send', 'error');
|
||||
showToast('toast.loras.noValidLorasToSend', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ export class BulkManager {
|
||||
|
||||
showBulkDeleteModal() {
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('No models selected', 'warning');
|
||||
showToast('toast.models.noModelsSelected', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -377,7 +377,7 @@ export class BulkManager {
|
||||
|
||||
async confirmBulkDelete() {
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('No models selected', 'warning');
|
||||
showToast('toast.models.noModelsSelected', {}, 'warning');
|
||||
modalManager.closeModal('bulkDeleteModal');
|
||||
return;
|
||||
}
|
||||
@@ -392,7 +392,10 @@ export class BulkManager {
|
||||
|
||||
if (result.success) {
|
||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
||||
showToast(`Successfully deleted ${result.deleted_count} ${currentConfig.displayName.toLowerCase()}(s)`, 'success');
|
||||
showToast('toast.models.deletedSuccessfully', {
|
||||
count: result.deleted_count,
|
||||
type: currentConfig.displayName.toLowerCase()
|
||||
}, 'success');
|
||||
|
||||
filePaths.forEach(path => {
|
||||
state.virtualScroller.removeItemByFilePath(path);
|
||||
@@ -403,11 +406,11 @@ export class BulkManager {
|
||||
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
|
||||
}
|
||||
} else {
|
||||
showToast(`Error: ${result.error || 'Failed to delete models'}`, 'error');
|
||||
showToast('toast.models.deleteFailed', { error: result.error || 'Failed to delete models' }, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during bulk delete:', error);
|
||||
showToast('Failed to delete models', 'error');
|
||||
showToast('toast.models.deleteFailedGeneral', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,7 +541,7 @@ export class BulkManager {
|
||||
|
||||
selectAllVisibleModels() {
|
||||
if (!state.virtualScroller || !state.virtualScroller.items) {
|
||||
showToast('Unable to select all items', 'error');
|
||||
showToast('toast.bulk.unableToSelectAll', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -565,7 +568,10 @@ export class BulkManager {
|
||||
|
||||
const newlySelected = state.selectedModels.size - oldCount;
|
||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
||||
showToast(`Selected ${newlySelected} additional ${currentConfig.displayName.toLowerCase()}(s)`, 'success');
|
||||
showToast('toast.models.selectedAdditional', {
|
||||
count: newlySelected,
|
||||
type: currentConfig.displayName.toLowerCase()
|
||||
}, 'success');
|
||||
|
||||
if (this.isStripVisible) {
|
||||
this.updateThumbnailStrip();
|
||||
@@ -574,7 +580,7 @@ export class BulkManager {
|
||||
|
||||
async refreshAllMetadata() {
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('No models selected', 'warning');
|
||||
showToast('toast.models.noModelsSelected', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -610,7 +616,7 @@ export class BulkManager {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during bulk metadata refresh:', error);
|
||||
showToast('Failed to refresh metadata', 'error');
|
||||
showToast('toast.models.refreshMetadataFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { LoadingManager } from './LoadingManager.js';
|
||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
|
||||
export class DownloadManager {
|
||||
constructor() {
|
||||
@@ -85,26 +86,26 @@ export class DownloadManager {
|
||||
const config = this.apiClient.apiConfig.config;
|
||||
|
||||
// Update modal title
|
||||
document.getElementById('downloadModalTitle').textContent = `Download ${config.displayName} from URL`;
|
||||
document.getElementById('downloadModalTitle').textContent = translate('modals.download.titleWithType', { type: config.displayName });
|
||||
|
||||
// Update URL label
|
||||
document.getElementById('modelUrlLabel').textContent = 'Civitai URL:';
|
||||
document.getElementById('modelUrlLabel').textContent = translate('modals.download.civitaiUrl');
|
||||
|
||||
// Update root selection label
|
||||
document.getElementById('modelRootLabel').textContent = `Select ${config.displayName} Root:`;
|
||||
document.getElementById('modelRootLabel').textContent = translate('modals.download.selectTypeRoot', { type: config.displayName });
|
||||
|
||||
// Update path preview labels
|
||||
const pathLabels = document.querySelectorAll('.path-preview label');
|
||||
pathLabels.forEach(label => {
|
||||
if (label.textContent.includes('Location Preview')) {
|
||||
label.textContent = 'Download Location Preview:';
|
||||
label.textContent = translate('modals.download.locationPreview') + ':';
|
||||
}
|
||||
});
|
||||
|
||||
// Update initial path text
|
||||
const pathText = document.querySelector('#targetPathDisplay .path-text');
|
||||
if (pathText) {
|
||||
pathText.textContent = `Select a ${config.displayName} root directory`;
|
||||
pathText.textContent = translate('modals.download.selectTypeRoot', { type: config.displayName });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,17 +143,17 @@ export class DownloadManager {
|
||||
const errorElement = document.getElementById('urlError');
|
||||
|
||||
try {
|
||||
this.loadingManager.showSimpleLoading('Fetching model versions...');
|
||||
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
|
||||
|
||||
this.modelId = this.extractModelId(url);
|
||||
if (!this.modelId) {
|
||||
throw new Error('Invalid Civitai URL format');
|
||||
throw new Error(translate('modals.download.errors.invalidUrl'));
|
||||
}
|
||||
|
||||
this.versions = await this.apiClient.fetchCivitaiVersions(this.modelId);
|
||||
|
||||
if (!this.versions.length) {
|
||||
throw new Error('No versions available for this model');
|
||||
throw new Error(translate('modals.download.errors.noVersions'));
|
||||
}
|
||||
|
||||
// If we have a version ID from URL, pre-select it
|
||||
@@ -199,15 +200,15 @@ export class DownloadManager {
|
||||
let earlyAccessBadge = '';
|
||||
if (isEarlyAccess) {
|
||||
earlyAccessBadge = `
|
||||
<div class="early-access-badge" title="Early access required">
|
||||
<i class="fas fa-clock"></i> Early Access
|
||||
<div class="early-access-badge" title="${translate('modals.download.earlyAccessTooltip')}">
|
||||
<i class="fas fa-clock"></i> ${translate('modals.download.earlyAccess')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const localStatus = existsLocally ?
|
||||
`<div class="local-badge">
|
||||
<i class="fas fa-check"></i> In Library
|
||||
<i class="fas fa-check"></i> ${translate('modals.download.inLibrary')}
|
||||
<div class="local-path">${localPath || ''}</div>
|
||||
</div>` : '';
|
||||
|
||||
@@ -217,7 +218,7 @@ export class DownloadManager {
|
||||
${isEarlyAccess ? 'is-early-access' : ''}"
|
||||
data-version-id="${version.id}">
|
||||
<div class="version-thumbnail">
|
||||
<img src="${thumbnailUrl}" alt="Version preview">
|
||||
<img src="${thumbnailUrl}" alt="${translate('modals.download.versionPreview')}">
|
||||
</div>
|
||||
<div class="version-content">
|
||||
<div class="version-header">
|
||||
@@ -273,23 +274,23 @@ export class DownloadManager {
|
||||
if (existsLocally) {
|
||||
nextButton.disabled = true;
|
||||
nextButton.classList.add('disabled');
|
||||
nextButton.textContent = 'Already in Library';
|
||||
nextButton.textContent = translate('modals.download.alreadyInLibrary');
|
||||
} else {
|
||||
nextButton.disabled = false;
|
||||
nextButton.classList.remove('disabled');
|
||||
nextButton.textContent = 'Next';
|
||||
nextButton.textContent = translate('common.actions.next');
|
||||
}
|
||||
}
|
||||
|
||||
async proceedToLocation() {
|
||||
if (!this.currentVersion) {
|
||||
showToast('Please select a version', 'error');
|
||||
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const existsLocally = this.currentVersion.existsLocally;
|
||||
if (existsLocally) {
|
||||
showToast('This version already exists in your library', 'info');
|
||||
showToast('toast.loras.versionExists', {}, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -343,7 +344,7 @@ export class DownloadManager {
|
||||
|
||||
this.updateTargetPath();
|
||||
} catch (error) {
|
||||
showToast(error.message, 'error');
|
||||
showToast('toast.downloads.loadError', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,7 +419,7 @@ export class DownloadManager {
|
||||
const config = this.apiClient.apiConfig.config;
|
||||
|
||||
if (!modelRoot) {
|
||||
showToast(`Please select a ${config.displayName} root directory`, 'error');
|
||||
showToast('toast.models.pleaseSelectRoot', { type: config.displayName }, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -455,13 +456,13 @@ export class DownloadManager {
|
||||
updateProgress(data.progress, 0, this.currentVersion.name);
|
||||
|
||||
if (data.progress < 3) {
|
||||
this.loadingManager.setStatus(`Preparing download...`);
|
||||
this.loadingManager.setStatus(translate('modals.download.status.preparing'));
|
||||
} else if (data.progress === 3) {
|
||||
this.loadingManager.setStatus(`Downloaded preview image`);
|
||||
this.loadingManager.setStatus(translate('modals.download.status.downloadedPreview'));
|
||||
} else if (data.progress > 3 && data.progress < 100) {
|
||||
this.loadingManager.setStatus(`Downloading ${config.singularName} file`);
|
||||
this.loadingManager.setStatus(translate('modals.download.status.downloadingFile', { type: config.singularName }));
|
||||
} else {
|
||||
this.loadingManager.setStatus(`Finalizing download...`);
|
||||
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -480,7 +481,7 @@ export class DownloadManager {
|
||||
downloadId
|
||||
);
|
||||
|
||||
showToast('Download completed successfully', 'success');
|
||||
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||
modalManager.closeModal('downloadModal');
|
||||
|
||||
ws.close();
|
||||
@@ -507,7 +508,7 @@ export class DownloadManager {
|
||||
await resetAndReload(true);
|
||||
|
||||
} catch (error) {
|
||||
showToast(error.message, 'error');
|
||||
showToast('toast.downloads.downloadError', { message: error.message }, 'error');
|
||||
} finally {
|
||||
this.loadingManager.hide();
|
||||
}
|
||||
@@ -523,11 +524,11 @@ export class DownloadManager {
|
||||
await this.folderTreeManager.loadTree(treeData.tree);
|
||||
} else {
|
||||
console.error('Failed to fetch folder tree:', treeData.error);
|
||||
showToast('Failed to load folder tree', 'error');
|
||||
showToast('toast.import.folderTreeFailed', {}, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing folder tree:', error);
|
||||
showToast('Error loading folder tree', 'error');
|
||||
showToast('toast.import.folderTreeError', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,7 +587,7 @@ export class DownloadManager {
|
||||
const modelRoot = document.getElementById('modelRoot').value;
|
||||
const config = this.apiClient.apiConfig.config;
|
||||
|
||||
let fullPath = modelRoot || `Select a ${config.displayName} root directory`;
|
||||
let fullPath = modelRoot || translate('modals.download.selectTypeRoot', { type: config.displayName });
|
||||
|
||||
if (modelRoot) {
|
||||
if (this.useDefaultPath) {
|
||||
@@ -598,7 +599,7 @@ export class DownloadManager {
|
||||
fullPath += `/${template}`;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch template:', error);
|
||||
fullPath += '/[Auto-organized by path template]';
|
||||
fullPath += '/' + translate('modals.download.autoOrganizedPath');
|
||||
}
|
||||
} else {
|
||||
// Show manual path selection
|
||||
|
||||
@@ -142,7 +142,7 @@ class ExampleImagesManager {
|
||||
if (!data.success) {
|
||||
console.error('Failed to update example images path in backend:', data.error);
|
||||
} else {
|
||||
showToast('Example images path updated successfully', 'success');
|
||||
showToast('toast.exampleImages.pathUpdated', {}, 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update example images path:', error);
|
||||
@@ -187,7 +187,7 @@ class ExampleImagesManager {
|
||||
this.startDownload();
|
||||
} else {
|
||||
// If download is in progress, show info toast
|
||||
showToast('Download already in progress', 'info');
|
||||
showToast('toast.exampleImages.downloadInProgress', {}, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ class ExampleImagesManager {
|
||||
|
||||
async startDownload() {
|
||||
if (this.isDownloading) {
|
||||
showToast('Download already in progress', 'warning');
|
||||
showToast('toast.exampleImages.downloadInProgress', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ class ExampleImagesManager {
|
||||
const outputDir = document.getElementById('exampleImagesPath').value || '';
|
||||
|
||||
if (!outputDir) {
|
||||
showToast('Please enter a download location first', 'warning');
|
||||
showToast('toast.exampleImages.enterLocationFirst', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -280,16 +280,16 @@ class ExampleImagesManager {
|
||||
this.showProgressPanel();
|
||||
this.startProgressUpdates();
|
||||
this.updateDownloadButtonText();
|
||||
showToast('Example images download started', 'success');
|
||||
showToast('toast.exampleImages.downloadStarted', {}, 'success');
|
||||
|
||||
// Close settings modal
|
||||
modalManager.closeModal('settingsModal');
|
||||
} else {
|
||||
showToast(data.error || 'Failed to start download', 'error');
|
||||
showToast('toast.exampleImages.downloadStartFailed', { error: data.error }, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start download:', error);
|
||||
showToast('Failed to start download', 'error');
|
||||
showToast('toast.exampleImages.downloadStartFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,13 +319,13 @@ class ExampleImagesManager {
|
||||
}
|
||||
|
||||
this.updateDownloadButtonText();
|
||||
showToast('Download paused', 'info');
|
||||
showToast('toast.exampleImages.downloadPaused', {}, 'info');
|
||||
} else {
|
||||
showToast(data.error || 'Failed to pause download', 'error');
|
||||
showToast('toast.exampleImages.pauseFailed', { error: data.error }, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pause download:', error);
|
||||
showToast('Failed to pause download', 'error');
|
||||
showToast('toast.exampleImages.pauseFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,13 +355,13 @@ class ExampleImagesManager {
|
||||
}
|
||||
|
||||
this.updateDownloadButtonText();
|
||||
showToast('Download resumed', 'success');
|
||||
showToast('toast.exampleImages.downloadResumed', {}, 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Failed to resume download', 'error');
|
||||
showToast('toast.exampleImages.resumeFailed', { error: data.error }, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to resume download:', error);
|
||||
showToast('Failed to resume download', 'error');
|
||||
showToast('toast.exampleImages.resumeFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,7 +399,7 @@ class ExampleImagesManager {
|
||||
|
||||
if (data.status.status === 'completed' && !this.hasShownCompletionToast) {
|
||||
const actionType = this.isMigrating ? 'migration' : 'download';
|
||||
showToast(`Example images ${actionType} completed`, 'success');
|
||||
showToast('toast.downloads.imagesCompleted', { action: actionType }, 'success');
|
||||
// Mark as shown to prevent duplicate toasts
|
||||
this.hasShownCompletionToast = true;
|
||||
// Reset migration flag
|
||||
@@ -408,7 +408,7 @@ class ExampleImagesManager {
|
||||
setTimeout(() => this.hideProgressPanel(), 5000);
|
||||
} else if (data.status.status === 'error') {
|
||||
const actionType = this.isMigrating ? 'migration' : 'download';
|
||||
showToast(`Example images ${actionType} failed`, 'error');
|
||||
showToast('toast.downloads.imagesFailed', { action: actionType }, 'error');
|
||||
this.isMigrating = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,12 +282,12 @@ export class FilterManager {
|
||||
message = `Filtering by ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
showToast(message, 'success');
|
||||
showToast('toast.filters.applied', { message }, 'success');
|
||||
}
|
||||
} else {
|
||||
this.filterButton.classList.remove('active');
|
||||
if (showToastNotification) {
|
||||
showToast('Filters cleared', 'info');
|
||||
showToast('toast.filters.cleared', {}, 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,7 +321,7 @@ export class FilterManager {
|
||||
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
||||
}
|
||||
|
||||
showToast(`Filters cleared`, 'info');
|
||||
showToast('toast.filters.cleared', {}, 'info');
|
||||
}
|
||||
|
||||
loadFiltersFromStorage() {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
|
||||
export class ImportManager {
|
||||
constructor() {
|
||||
@@ -110,7 +111,7 @@ export class ImportManager {
|
||||
if (recipeName) recipeName.value = '';
|
||||
|
||||
const tagsContainer = document.getElementById('tagsContainer');
|
||||
if (tagsContainer) tagsContainer.innerHTML = '<div class="empty-tags">No tags added</div>';
|
||||
if (tagsContainer) tagsContainer.innerHTML = `<div class="empty-tags">${translate('recipes.controls.import.noTagsAdded', {}, 'No tags added')}</div>`;
|
||||
|
||||
// Clear folder path input
|
||||
const folderPathInput = document.getElementById('importFolderPath');
|
||||
@@ -261,7 +262,7 @@ export class ImportManager {
|
||||
|
||||
this.updateTargetPath();
|
||||
} catch (error) {
|
||||
showToast(error.message, 'error');
|
||||
showToast('toast.recipes.importFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,11 +351,11 @@ export class ImportManager {
|
||||
await this.folderTreeManager.loadTree(treeData.tree);
|
||||
} else {
|
||||
console.error('Failed to fetch folder tree:', treeData.error);
|
||||
showToast('Failed to load folder tree', 'error');
|
||||
showToast('toast.recipes.folderTreeFailed', {}, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing folder tree:', error);
|
||||
showToast('Error loading folder tree', 'error');
|
||||
showToast('toast.recipes.folderTreeError', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,9 +369,7 @@ export class ImportManager {
|
||||
const pathDisplay = document.getElementById('importTargetPathDisplay');
|
||||
const loraRoot = document.getElementById('importLoraRoot').value;
|
||||
|
||||
let fullPath = loraRoot || 'Select a LoRA root directory';
|
||||
|
||||
if (loraRoot) {
|
||||
let fullPath = loraRoot || translate('recipes.controls.import.selectLoraRoot', {}, 'Select a LoRA root directory'); if (loraRoot) {
|
||||
if (this.useDefaultPath) {
|
||||
// Show actual template path
|
||||
try {
|
||||
@@ -425,11 +424,11 @@ export class ImportManager {
|
||||
|
||||
// Update the modal title
|
||||
const modalTitle = document.querySelector('#importModal h2');
|
||||
if (modalTitle) modalTitle.textContent = 'Download Missing LoRAs';
|
||||
if (modalTitle) modalTitle.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs');
|
||||
|
||||
// Update the save button text
|
||||
const saveButton = document.querySelector('#locationStep .primary-btn');
|
||||
if (saveButton) saveButton.textContent = 'Download Missing LoRAs';
|
||||
if (saveButton) saveButton.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs');
|
||||
|
||||
// Hide the back button
|
||||
const backButton = document.querySelector('#locationStep .secondary-btn');
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { showToast, updateFolderTags } from '../utils/uiHelpers.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { bulkManager } from './BulkManager.js';
|
||||
import { getStorageItem } from '../utils/storageHelpers.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||
import { sidebarManager } from '../components/SidebarManager.js';
|
||||
|
||||
class MoveManager {
|
||||
constructor() {
|
||||
@@ -44,7 +45,7 @@ class MoveManager {
|
||||
if (filePath === 'bulk') {
|
||||
const selectedPaths = Array.from(state.selectedModels);
|
||||
if (selectedPaths.length === 0) {
|
||||
showToast('No models selected', 'warning');
|
||||
showToast('toast.models.noModelsSelected', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
this.bulkFilePaths = selectedPaths;
|
||||
@@ -115,7 +116,7 @@ class MoveManager {
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${modelConfig.displayName.toLowerCase()} roots or folders:`, error);
|
||||
showToast(error.message, 'error');
|
||||
showToast('toast.models.moveFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,11 +131,11 @@ class MoveManager {
|
||||
await this.folderTreeManager.loadTree(treeData.tree);
|
||||
} else {
|
||||
console.error('Failed to fetch folder tree:', treeData.error);
|
||||
showToast('Failed to load folder tree', 'error');
|
||||
showToast('toast.import.folderTreeFailed', {}, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing folder tree:', error);
|
||||
showToast('Error loading folder tree', 'error');
|
||||
showToast('toast.import.folderTreeError', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +163,7 @@ class MoveManager {
|
||||
const config = apiClient.apiConfig.config;
|
||||
|
||||
if (!selectedRoot) {
|
||||
showToast(`Please select a ${config.displayName.toLowerCase()} root directory`, 'error');
|
||||
showToast('toast.models.pleaseSelectRoot', { type: config.displayName.toLowerCase() }, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -177,51 +178,54 @@ class MoveManager {
|
||||
try {
|
||||
if (this.bulkFilePaths) {
|
||||
// Bulk move mode
|
||||
const movedFilePaths = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath);
|
||||
const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath);
|
||||
|
||||
// Update virtual scroller if in active folder view
|
||||
const pageState = getCurrentPageState();
|
||||
if (pageState.activeFolder !== null && state.virtualScroller) {
|
||||
// Remove only successfully moved items
|
||||
movedFilePaths.forEach(newFilePath => {
|
||||
// Find original filePath by matching filename
|
||||
const filename = newFilePath.substring(newFilePath.lastIndexOf('/') + 1);
|
||||
const originalFilePath = this.bulkFilePaths.find(fp => fp.endsWith('/' + filename));
|
||||
if (originalFilePath) {
|
||||
state.virtualScroller.removeItemByFilePath(originalFilePath);
|
||||
// Remove items that were successfully moved
|
||||
results.forEach(result => {
|
||||
if (result.success) {
|
||||
state.virtualScroller.removeItemByFilePath(result.original_file_path);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Update the model cards' filepath in the DOM
|
||||
movedFilePaths.forEach(newFilePath => {
|
||||
const filename = newFilePath.substring(newFilePath.lastIndexOf('/') + 1);
|
||||
const originalFilePath = this.bulkFilePaths.find(fp => fp.endsWith('/' + filename));
|
||||
if (originalFilePath) {
|
||||
state.virtualScroller.updateSingleItem(originalFilePath, {file_path: newFilePath});
|
||||
// Update the model cards' filepath and filename in the DOM
|
||||
results.forEach(result => {
|
||||
if (result.success && result.new_file_path !== result.original_file_path) {
|
||||
const newFileName = result.new_file_path.substring(result.new_file_path.lastIndexOf('/') + 1);
|
||||
const baseFileName = newFileName.substring(0, newFileName.lastIndexOf('.'));
|
||||
|
||||
state.virtualScroller.updateSingleItem(result.original_file_path, {
|
||||
file_path: result.new_file_path,
|
||||
file_name: baseFileName
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Single move mode
|
||||
const newFilePath = await apiClient.moveSingleModel(this.currentFilePath, targetPath);
|
||||
const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath);
|
||||
|
||||
const pageState = getCurrentPageState();
|
||||
if (newFilePath) {
|
||||
if (result && result.new_file_path) {
|
||||
if (pageState.activeFolder !== null && state.virtualScroller) {
|
||||
state.virtualScroller.removeItemByFilePath(this.currentFilePath);
|
||||
} else {
|
||||
state.virtualScroller.updateSingleItem(this.currentFilePath, {file_path: newFilePath});
|
||||
} else if (result.new_file_path !== this.currentFilePath) {
|
||||
// Update both file_path and file_name if they changed
|
||||
const newFileName = result.new_file_path.substring(result.new_file_path.lastIndexOf('/') + 1);
|
||||
const baseFileName = newFileName.substring(0, newFileName.lastIndexOf('.'));
|
||||
|
||||
state.virtualScroller.updateSingleItem(this.currentFilePath, {
|
||||
file_path: result.new_file_path,
|
||||
file_name: baseFileName
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh folder tags after successful move
|
||||
try {
|
||||
const foldersData = await apiClient.fetchModelFolders();
|
||||
updateFolderTags(foldersData.folders);
|
||||
} catch (error) {
|
||||
console.error('Error refreshing folder tags:', error);
|
||||
}
|
||||
sidebarManager.refresh();
|
||||
|
||||
modalManager.closeModal('moveModal');
|
||||
|
||||
@@ -232,7 +236,7 @@ class MoveManager {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error moving model(s):', error);
|
||||
showToast('Failed to move model(s): ' + error.message, 'error');
|
||||
showToast('toast.models.moveFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
488
static/js/managers/OnboardingManager.js
Normal file
488
static/js/managers/OnboardingManager.js
Normal file
@@ -0,0 +1,488 @@
|
||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
|
||||
export class OnboardingManager {
|
||||
constructor() {
|
||||
this.isActive = false;
|
||||
this.currentStep = 0;
|
||||
this.selectedLanguage = 'en'; // Will be updated from state
|
||||
this.overlay = null;
|
||||
this.spotlight = null;
|
||||
this.popup = null;
|
||||
this.currentTarget = null; // Track current highlighted element
|
||||
|
||||
// Available languages with SVG flags (using flag-icons)
|
||||
this.languages = [
|
||||
{ code: 'en', name: 'English', flag: 'us' },
|
||||
{ code: 'zh-CN', name: '简体中文', flag: 'cn' },
|
||||
{ code: 'zh-TW', name: '繁體中文', flag: 'hk' },
|
||||
{ code: 'ja', name: '日本語', flag: 'jp' },
|
||||
{ code: 'ko', name: '한국어', flag: 'kr' },
|
||||
{ code: 'es', name: 'Español', flag: 'es' },
|
||||
{ code: 'fr', name: 'Français', flag: 'fr' },
|
||||
{ code: 'de', name: 'Deutsch', flag: 'de' },
|
||||
{ code: 'ru', name: 'Русский', flag: 'ru' }
|
||||
];
|
||||
|
||||
// Tutorial steps configuration
|
||||
this.steps = [
|
||||
{
|
||||
target: '.controls .action-buttons [data-action="fetch"]',
|
||||
title: () => translate('onboarding.steps.fetch.title', {}, 'Fetch Models Metadata'),
|
||||
content: () => translate('onboarding.steps.fetch.content', {}, 'Click the <strong>Fetch</strong> button to download model metadata and preview images from Civitai.'),
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
target: '.controls .action-buttons [data-action="download"]',
|
||||
title: () => translate('onboarding.steps.download.title', {}, 'Download New Models'),
|
||||
content: () => translate('onboarding.steps.download.content', {}, 'Use the <strong>Download</strong> button to download models directly from Civitai URLs.'),
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
target: '.controls .action-buttons [data-action="bulk"]',
|
||||
title: () => translate('onboarding.steps.bulk.title', {}, 'Bulk Operations'),
|
||||
content: () => translate('onboarding.steps.bulk.content', {}, 'Enter bulk mode by clicking this button or pressing <span class="onboarding-shortcut">B</span>. Select multiple models and perform batch operations. Use <span class="onboarding-shortcut">Ctrl+A</span> to select all visible models.'),
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
target: '#searchOptionsToggle',
|
||||
title: () => translate('onboarding.steps.searchOptions.title', {}, 'Search Options'),
|
||||
content: () => translate('onboarding.steps.searchOptions.content', {}, 'Click this button to configure what fields to search in: filename, model name, tags, or creator name. Customize your search scope.'),
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
target: '#filterButton',
|
||||
title: () => translate('onboarding.steps.filter.title', {}, 'Filter Models'),
|
||||
content: () => translate('onboarding.steps.filter.content', {}, 'Use filters to narrow down models by base model type (SD1.5, SDXL, Flux, etc.) or by specific tags.'),
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
target: '#breadcrumbContainer',
|
||||
title: () => translate('onboarding.steps.breadcrumb.title', {}, 'Breadcrumb Navigation'),
|
||||
content: () => translate('onboarding.steps.breadcrumb.content', {}, 'The breadcrumb navigation shows your current path and allows quick navigation between folders. Click any folder name to jump directly there.'),
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
target: '.card-grid',
|
||||
title: () => translate('onboarding.steps.modelCards.title', {}, 'Model Cards'),
|
||||
content: () => translate('onboarding.steps.modelCards.content', {}, '<strong>Single-click</strong> a model card to view detailed information and edit metadata. Look for the pencil icon when hovering over editable fields.'),
|
||||
position: 'top',
|
||||
customPosition: { top: '20%', left: '50%' }
|
||||
},
|
||||
{
|
||||
target: '.card-grid',
|
||||
title: () => translate('onboarding.steps.contextMenu.title', {}, 'Context Menu'),
|
||||
content: () => translate('onboarding.steps.contextMenu.content', {}, '<strong>Right-click</strong> any model card for a context menu with additional actions.'),
|
||||
position: 'top',
|
||||
customPosition: { top: '20%', left: '50%' }
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Check if user should see onboarding
|
||||
shouldShowOnboarding() {
|
||||
const completed = getStorageItem('onboarding_completed');
|
||||
const skipped = getStorageItem('onboarding_skipped');
|
||||
return !completed && !skipped;
|
||||
}
|
||||
|
||||
// Start the onboarding process
|
||||
async start() {
|
||||
if (!this.shouldShowOnboarding()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If language has already been set, skip language selection
|
||||
if (getStorageItem('onboarding_language_set')) {
|
||||
this.startTutorial();
|
||||
return;
|
||||
}
|
||||
|
||||
// Show language selection first
|
||||
await this.showLanguageSelection();
|
||||
}
|
||||
|
||||
// Show language selection modal
|
||||
showLanguageSelection() {
|
||||
return new Promise((resolve) => {
|
||||
// Initialize selected language from current settings
|
||||
this.selectedLanguage = state.global.settings.language || 'en';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'language-selection-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="language-selection-content">
|
||||
<h2>${translate('onboarding.languageSelection.title', {}, 'Welcome to LoRA Manager')}</h2>
|
||||
<p>Choose Your Language / 选择语言 / 言語を選択</p>
|
||||
<div class="language-grid">
|
||||
${this.languages.map(lang => `
|
||||
<div class="language-option" data-language="${lang.code}">
|
||||
<span class="language-flag">
|
||||
<span class="fi fi-${lang.flag}"></span>
|
||||
</span>
|
||||
<span class="language-name">${lang.name}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div class="language-actions">
|
||||
<button class="onboarding-btn" id="skipLanguageBtn">${translate('onboarding.tutorial.skipTutorial', {}, 'Skip Tutorial')}</button>
|
||||
<button class="onboarding-btn primary" id="continueLanguageBtn">${translate('onboarding.languageSelection.continue', {}, 'Continue')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Handle language selection
|
||||
modal.querySelectorAll('.language-option').forEach(option => {
|
||||
option.addEventListener('click', () => {
|
||||
modal.querySelectorAll('.language-option').forEach(opt => opt.classList.remove('selected'));
|
||||
option.classList.add('selected');
|
||||
this.selectedLanguage = option.dataset.language;
|
||||
});
|
||||
});
|
||||
|
||||
// Handle continue button
|
||||
document.getElementById('continueLanguageBtn').addEventListener('click', async () => {
|
||||
const currentLanguage = state.global.settings.language || 'en';
|
||||
|
||||
// Only change language if it's different from current setting
|
||||
if (this.selectedLanguage !== currentLanguage) {
|
||||
await this.changeLanguage(this.selectedLanguage);
|
||||
} else {
|
||||
document.body.removeChild(modal);
|
||||
this.startTutorial();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle skip button - skip entire tutorial
|
||||
document.getElementById('skipLanguageBtn').addEventListener('click', () => {
|
||||
document.body.removeChild(modal);
|
||||
this.skip(); // Skip entire tutorial instead of just language selection
|
||||
resolve();
|
||||
});
|
||||
|
||||
// Select current language by default
|
||||
const currentLanguageOption = modal.querySelector(`[data-language="${this.selectedLanguage}"]`);
|
||||
if (currentLanguageOption) {
|
||||
currentLanguageOption.classList.add('selected');
|
||||
} else {
|
||||
// Fallback to English if current language not found
|
||||
modal.querySelector('[data-language="en"]').classList.add('selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Change language using existing settings manager
|
||||
async changeLanguage(languageCode) {
|
||||
try {
|
||||
// Update state
|
||||
state.global.settings.language = languageCode;
|
||||
|
||||
// Save to localStorage
|
||||
setStorageItem('settings', state.global.settings);
|
||||
|
||||
// Save to backend
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
language: languageCode
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Mark onboarding as started before reload
|
||||
setStorageItem('onboarding_language_set', true);
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to change language:', error);
|
||||
showToast('onboarding.languageSelection.changeFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Start the tutorial steps
|
||||
startTutorial() {
|
||||
this.isActive = true;
|
||||
this.currentStep = 0;
|
||||
this.createOverlay();
|
||||
this.showStep(0);
|
||||
}
|
||||
|
||||
// Create overlay elements
|
||||
createOverlay() {
|
||||
// Create overlay
|
||||
this.overlay = document.createElement('div');
|
||||
this.overlay.className = 'onboarding-overlay active';
|
||||
document.body.appendChild(this.overlay);
|
||||
|
||||
// Create spotlight
|
||||
this.spotlight = document.createElement('div');
|
||||
this.spotlight.className = 'onboarding-spotlight';
|
||||
document.body.appendChild(this.spotlight);
|
||||
|
||||
// Create popup
|
||||
this.popup = document.createElement('div');
|
||||
this.popup.className = 'onboarding-popup';
|
||||
document.body.appendChild(this.popup);
|
||||
}
|
||||
|
||||
// Show specific step
|
||||
showStep(stepIndex) {
|
||||
if (stepIndex >= this.steps.length) {
|
||||
this.complete();
|
||||
return;
|
||||
}
|
||||
|
||||
const step = this.steps[stepIndex];
|
||||
const target = document.querySelector(step.target);
|
||||
|
||||
if (!target && step.target !== 'body') {
|
||||
// Skip this step if target not found
|
||||
this.showStep(stepIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear previous target highlighting
|
||||
this.clearTargetHighlight();
|
||||
|
||||
// Position spotlight and create mask
|
||||
if (target && step.target !== 'body') {
|
||||
this.highlightTarget(target);
|
||||
} else {
|
||||
this.spotlight.style.display = 'none';
|
||||
this.clearOverlayMask();
|
||||
}
|
||||
|
||||
// Update popup content
|
||||
this.popup.innerHTML = `
|
||||
<h3>${typeof step.title === 'function' ? step.title() : step.title}</h3>
|
||||
<p>${typeof step.content === 'function' ? step.content() : step.content}</p>
|
||||
<div class="onboarding-controls">
|
||||
<div class="onboarding-progress">
|
||||
<span>${stepIndex + 1} / ${this.steps.length}</span>
|
||||
</div>
|
||||
<div class="onboarding-actions">
|
||||
<button class="onboarding-btn" onclick="onboardingManager.skip()">${translate('onboarding.tutorial.skipTutorial', {}, 'Skip Tutorial')}</button>
|
||||
${stepIndex > 0 ? `<button class="onboarding-btn" onclick="onboardingManager.previousStep()">${translate('onboarding.tutorial.back', {}, 'Back')}</button>` : ''}
|
||||
<button class="onboarding-btn primary" onclick="onboardingManager.nextStep()">
|
||||
${stepIndex === this.steps.length - 1 ? translate('onboarding.tutorial.finish', {}, 'Finish') : translate('onboarding.tutorial.next', {}, 'Next')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Position popup
|
||||
this.positionPopup(step, target);
|
||||
|
||||
this.currentStep = stepIndex;
|
||||
}
|
||||
|
||||
// Position popup relative to target
|
||||
positionPopup(step, target) {
|
||||
const popup = this.popup;
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if (step.customPosition) {
|
||||
popup.style.left = step.customPosition.left;
|
||||
popup.style.top = step.customPosition.top;
|
||||
popup.style.transform = 'translate(-50%, 0)';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target || step.target === 'body') {
|
||||
popup.style.left = '50%';
|
||||
popup.style.top = '50%';
|
||||
popup.style.transform = 'translate(-50%, -50%)';
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
const popupRect = popup.getBoundingClientRect();
|
||||
|
||||
let left, top;
|
||||
|
||||
switch (step.position) {
|
||||
case 'bottom':
|
||||
left = rect.left + (rect.width / 2) - (popupRect.width / 2);
|
||||
top = rect.bottom + 20;
|
||||
break;
|
||||
case 'top':
|
||||
left = rect.left + (rect.width / 2) - (popupRect.width / 2);
|
||||
top = rect.top - popupRect.height - 20;
|
||||
break;
|
||||
case 'right':
|
||||
left = rect.right + 20;
|
||||
top = rect.top + (rect.height / 2) - (popupRect.height / 2);
|
||||
break;
|
||||
case 'left':
|
||||
left = rect.left - popupRect.width - 20;
|
||||
top = rect.top + (rect.height / 2) - (popupRect.height / 2);
|
||||
break;
|
||||
default:
|
||||
left = rect.left + (rect.width / 2) - (popupRect.width / 2);
|
||||
top = rect.bottom + 20;
|
||||
}
|
||||
|
||||
// Ensure popup stays within viewport
|
||||
left = Math.max(20, Math.min(left, windowWidth - popupRect.width - 20));
|
||||
top = Math.max(20, Math.min(top, windowHeight - popupRect.height - 20));
|
||||
|
||||
popup.style.left = `${left}px`;
|
||||
popup.style.top = `${top}px`;
|
||||
popup.style.transform = 'none';
|
||||
}
|
||||
|
||||
// Highlight target element with mask approach
|
||||
highlightTarget(target) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
const padding = 4; // Padding around the target element
|
||||
const offset = 3; // Shift spotlight up and left by 3px
|
||||
|
||||
// Position spotlight
|
||||
this.spotlight.style.left = `${rect.left - padding - offset}px`;
|
||||
this.spotlight.style.top = `${rect.top - padding - offset}px`;
|
||||
this.spotlight.style.width = `${rect.width + padding * 2}px`;
|
||||
this.spotlight.style.height = `${rect.height + padding * 2}px`;
|
||||
this.spotlight.style.display = 'block';
|
||||
|
||||
// Create mask for overlay to cut out the highlighted area
|
||||
this.createOverlayMask(rect, padding, offset);
|
||||
|
||||
// Add highlight class to target and ensure it's interactive
|
||||
target.classList.add('onboarding-target-highlight');
|
||||
this.currentTarget = target;
|
||||
|
||||
// Add pulsing animation
|
||||
this.spotlight.classList.add('onboarding-highlight');
|
||||
}
|
||||
|
||||
// Create mask for overlay to cut out highlighted area
|
||||
createOverlayMask(rect, padding, offset = 0) {
|
||||
const x = rect.left - padding - offset;
|
||||
const y = rect.top - padding - offset;
|
||||
const width = rect.width + padding * 2;
|
||||
const height = rect.height + padding * 2;
|
||||
|
||||
// Create SVG mask
|
||||
const maskId = 'onboarding-mask';
|
||||
let maskSvg = document.getElementById(maskId);
|
||||
|
||||
if (!maskSvg) {
|
||||
maskSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
maskSvg.id = maskId;
|
||||
maskSvg.style.position = 'absolute';
|
||||
maskSvg.style.top = '0';
|
||||
maskSvg.style.left = '0';
|
||||
maskSvg.style.width = '100%';
|
||||
maskSvg.style.height = '100%';
|
||||
maskSvg.style.pointerEvents = 'none';
|
||||
document.body.appendChild(maskSvg);
|
||||
}
|
||||
|
||||
// Clear existing mask content
|
||||
maskSvg.innerHTML = `
|
||||
<defs>
|
||||
<mask id="overlay-mask">
|
||||
<rect width="100%" height="100%" fill="white"/>
|
||||
<rect x="${x}" y="${y}" width="${width}" height="${height}"
|
||||
rx="8" ry="8" fill="black"/>
|
||||
</mask>
|
||||
</defs>
|
||||
`;
|
||||
|
||||
// Apply mask to overlay
|
||||
this.overlay.style.mask = 'url(#overlay-mask)';
|
||||
this.overlay.style.webkitMask = 'url(#overlay-mask)';
|
||||
}
|
||||
|
||||
// Clear overlay mask
|
||||
clearOverlayMask() {
|
||||
if (this.overlay) {
|
||||
this.overlay.style.mask = 'none';
|
||||
this.overlay.style.webkitMask = 'none';
|
||||
}
|
||||
|
||||
const maskSvg = document.getElementById('onboarding-mask');
|
||||
if (maskSvg) {
|
||||
maskSvg.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Clear target highlighting
|
||||
clearTargetHighlight() {
|
||||
if (this.currentTarget) {
|
||||
this.currentTarget.classList.remove('onboarding-target-highlight');
|
||||
this.currentTarget = null;
|
||||
}
|
||||
|
||||
if (this.spotlight) {
|
||||
this.spotlight.classList.remove('onboarding-highlight');
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to next step
|
||||
nextStep() {
|
||||
this.showStep(this.currentStep + 1);
|
||||
}
|
||||
|
||||
// Navigate to previous step
|
||||
previousStep() {
|
||||
if (this.currentStep > 0) {
|
||||
this.showStep(this.currentStep - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip the tutorial
|
||||
skip() {
|
||||
setStorageItem('onboarding_skipped', true);
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
// Complete the tutorial
|
||||
complete() {
|
||||
setStorageItem('onboarding_completed', true);
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
// Clean up overlay elements
|
||||
cleanup() {
|
||||
this.clearTargetHighlight();
|
||||
this.clearOverlayMask();
|
||||
|
||||
if (this.overlay) {
|
||||
document.body.removeChild(this.overlay);
|
||||
this.overlay = null;
|
||||
}
|
||||
if (this.spotlight) {
|
||||
document.body.removeChild(this.spotlight);
|
||||
this.spotlight = null;
|
||||
}
|
||||
if (this.popup) {
|
||||
document.body.removeChild(this.popup);
|
||||
this.popup = null;
|
||||
}
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
// Reset onboarding status (for testing)
|
||||
reset() {
|
||||
localStorage.removeItem('lora_manager_onboarding_completed');
|
||||
localStorage.removeItem('lora_manager_onboarding_skipped');
|
||||
localStorage.removeItem('lora_manager_onboarding_language_set');
|
||||
localStorage.setItem('lora_manager_version_info', '0.8.30-2546581');
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const onboardingManager = new OnboardingManager();
|
||||
|
||||
// Make it globally available for button handlers
|
||||
window.onboardingManager = onboardingManager;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { updatePanelPositions } from "../utils/uiHelpers.js";
|
||||
import { updatePanelPositions, showToast } from "../utils/uiHelpers.js";
|
||||
import { getCurrentPageState } from "../state/index.js";
|
||||
import { getModelApiClient } from "../api/modelApiFactory.js";
|
||||
import { setStorageItem, getStorageItem } from "../utils/storageHelpers.js";
|
||||
@@ -19,7 +19,6 @@ export class SearchManager {
|
||||
this.searchOptionsPanel = document.getElementById('searchOptionsPanel');
|
||||
this.closeSearchOptions = document.getElementById('closeSearchOptions');
|
||||
this.searchOptionTags = document.querySelectorAll('.search-option-tag');
|
||||
this.recursiveSearchToggle = document.getElementById('recursiveSearchToggle');
|
||||
|
||||
this.searchTimeout = null;
|
||||
this.currentPage = options.page || document.body.dataset.page || 'loras';
|
||||
@@ -98,10 +97,7 @@ export class SearchManager {
|
||||
// Check if clicking would deselect the last active option
|
||||
const activeOptions = document.querySelectorAll('.search-option-tag.active');
|
||||
if (activeOptions.length === 1 && activeOptions[0] === tag) {
|
||||
// Don't allow deselecting the last option
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('At least one search option must be selected', 'info');
|
||||
}
|
||||
showToast('toast.search.atLeastOneOption', {}, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -112,14 +108,6 @@ export class SearchManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Recursive search toggle
|
||||
if (this.recursiveSearchToggle) {
|
||||
this.recursiveSearchToggle.addEventListener('change', () => {
|
||||
this.saveSearchPreferences();
|
||||
this.performSearch();
|
||||
});
|
||||
}
|
||||
|
||||
// Add global click handler to close panels when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
// Close search options panel when clicking outside
|
||||
@@ -218,11 +206,6 @@ export class SearchManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Apply recursive search - only if the toggle exists
|
||||
if (this.recursiveSearchToggle && preferences.recursive !== undefined) {
|
||||
this.recursiveSearchToggle.checked = preferences.recursive;
|
||||
}
|
||||
|
||||
// Ensure at least one search option is selected
|
||||
this.validateSearchOptions();
|
||||
} catch (error) {
|
||||
@@ -272,11 +255,6 @@ export class SearchManager {
|
||||
options
|
||||
};
|
||||
|
||||
// Only add recursive option if the toggle exists
|
||||
if (this.recursiveSearchToggle) {
|
||||
preferences.recursive = this.recursiveSearchToggle.checked;
|
||||
}
|
||||
|
||||
setStorageItem(`${this.currentPage}_search_prefs`, preferences);
|
||||
} catch (error) {
|
||||
console.error('Error saving search preferences:', error);
|
||||
@@ -294,7 +272,6 @@ export class SearchManager {
|
||||
performSearch() {
|
||||
const query = this.searchInput.value.trim();
|
||||
const options = this.getActiveSearchOptions();
|
||||
const recursive = this.recursiveSearchToggle ? this.recursiveSearchToggle.checked : false;
|
||||
|
||||
// Update the state with search parameters
|
||||
const pageState = getCurrentPageState();
|
||||
@@ -318,16 +295,14 @@ export class SearchManager {
|
||||
filename: options.filename || false,
|
||||
modelname: options.modelname || false,
|
||||
tags: options.tags || false,
|
||||
creator: options.creator || false,
|
||||
recursive: recursive
|
||||
creator: options.creator || false
|
||||
};
|
||||
} else if (this.currentPage === 'checkpoints') {
|
||||
pageState.searchOptions = {
|
||||
filename: options.filename || false,
|
||||
modelname: options.modelname || false,
|
||||
tags: options.tags || false,
|
||||
creator: options.creator || false,
|
||||
recursive: recursive
|
||||
creator: options.creator || false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { state } from '../state/index.js';
|
||||
import { resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
||||
import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES } from '../utils/constants.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
|
||||
export class SettingsManager {
|
||||
constructor() {
|
||||
@@ -270,6 +271,13 @@ export class SettingsManager {
|
||||
|
||||
// Load default embedding root
|
||||
await this.loadEmbeddingRoots();
|
||||
|
||||
// Load language setting
|
||||
const languageSelect = document.getElementById('languageSelect');
|
||||
if (languageSelect) {
|
||||
const currentLanguage = state.global.settings.language || 'en';
|
||||
languageSelect.value = currentLanguage;
|
||||
}
|
||||
}
|
||||
|
||||
async loadLoraRoots() {
|
||||
@@ -307,7 +315,7 @@ export class SettingsManager {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading LoRA roots:', error);
|
||||
showToast('Failed to load LoRA roots: ' + error.message, 'error');
|
||||
showToast('toast.settings.loraRootsFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +354,7 @@ export class SettingsManager {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading checkpoint roots:', error);
|
||||
showToast('Failed to load checkpoint roots: ' + error.message, 'error');
|
||||
showToast('toast.settings.checkpointRootsFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,7 +393,7 @@ export class SettingsManager {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading embedding roots:', error);
|
||||
showToast('Failed to load embedding roots: ' + error.message, 'error');
|
||||
showToast('toast.settings.embeddingRootsFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,13 +432,13 @@ export class SettingsManager {
|
||||
row.innerHTML = `
|
||||
<div class="mapping-controls">
|
||||
<select class="base-model-select">
|
||||
<option value="">Select Base Model</option>
|
||||
<option value="">${translate('settings.downloadPathTemplates.selectBaseModel', {}, 'Select Base Model')}</option>
|
||||
${availableModels.map(model =>
|
||||
`<option value="${model}" ${model === baseModel ? 'selected' : ''}>${model}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
<input type="text" class="path-value-input" placeholder="Custom path (e.g., flux)" value="${pathValue}">
|
||||
<button type="button" class="remove-mapping-btn" title="Remove mapping">
|
||||
<input type="text" class="path-value-input" placeholder="${translate('settings.downloadPathTemplates.customPathPlaceholder', {}, 'Custom path (e.g., flux)')}" value="${pathValue}">
|
||||
<button type="button" class="remove-mapping-btn" title="${translate('settings.downloadPathTemplates.removeMapping', {}, 'Remove mapping')}">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -523,7 +531,7 @@ export class SettingsManager {
|
||||
);
|
||||
|
||||
// Rebuild options
|
||||
select.innerHTML = '<option value="">Select Base Model</option>' +
|
||||
select.innerHTML = `<option value="">${translate('settings.downloadPathTemplates.selectBaseModel', {}, 'Select Base Model')}</option>` +
|
||||
availableModels.map(model =>
|
||||
`<option value="${model}" ${model === currentValue ? 'selected' : ''}>${model}</option>`
|
||||
).join('');
|
||||
@@ -553,14 +561,17 @@ export class SettingsManager {
|
||||
// Show success toast
|
||||
const mappingCount = Object.keys(state.global.settings.base_model_path_mappings).length;
|
||||
if (mappingCount > 0) {
|
||||
showToast(`Base model path mappings updated (${mappingCount} mapping${mappingCount !== 1 ? 's' : ''})`, 'success');
|
||||
showToast('toast.settings.mappingsUpdated', {
|
||||
count: mappingCount,
|
||||
plural: mappingCount !== 1 ? 's' : ''
|
||||
}, 'success');
|
||||
} else {
|
||||
showToast('Base model path mappings cleared', 'success');
|
||||
showToast('toast.settings.mappingsCleared', {}, 'success');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving base model mappings:', error);
|
||||
showToast('Failed to save base model mappings: ' + error.message, 'error');
|
||||
showToast('toast.settings.mappingSaveFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,7 +663,7 @@ export class SettingsManager {
|
||||
validationElement.className = 'template-validation';
|
||||
|
||||
if (!template) {
|
||||
validationElement.innerHTML = '<i class="fas fa-check"></i> Valid (flat structure)';
|
||||
validationElement.innerHTML = `<i class="fas fa-check"></i> ${translate('settings.downloadPathTemplates.validation.validFlat', {}, 'Valid (flat structure)')}`;
|
||||
validationElement.classList.add('valid');
|
||||
return true;
|
||||
}
|
||||
@@ -660,21 +671,21 @@ export class SettingsManager {
|
||||
// Check for invalid characters
|
||||
const invalidChars = /[<>:"|?*]/;
|
||||
if (invalidChars.test(template)) {
|
||||
validationElement.innerHTML = '<i class="fas fa-times"></i> Invalid characters detected';
|
||||
validationElement.innerHTML = `<i class="fas fa-times"></i> ${translate('settings.downloadPathTemplates.validation.invalidChars', {}, 'Invalid characters detected')}`;
|
||||
validationElement.classList.add('invalid');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for double slashes
|
||||
if (template.includes('//')) {
|
||||
validationElement.innerHTML = '<i class="fas fa-times"></i> Double slashes not allowed';
|
||||
validationElement.innerHTML = `<i class="fas fa-times"></i> ${translate('settings.downloadPathTemplates.validation.doubleSlashes', {}, 'Double slashes not allowed')}`;
|
||||
validationElement.classList.add('invalid');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it starts or ends with slash
|
||||
if (template.startsWith('/') || template.endsWith('/')) {
|
||||
validationElement.innerHTML = '<i class="fas fa-times"></i> Cannot start or end with slash';
|
||||
validationElement.innerHTML = `<i class="fas fa-times"></i> ${translate('settings.downloadPathTemplates.validation.leadingTrailingSlash', {}, 'Cannot start or end with slash')}`;
|
||||
validationElement.classList.add('invalid');
|
||||
return false;
|
||||
}
|
||||
@@ -689,13 +700,13 @@ export class SettingsManager {
|
||||
);
|
||||
|
||||
if (invalidPlaceholders.length > 0) {
|
||||
validationElement.innerHTML = `<i class="fas fa-times"></i> Invalid placeholder: ${invalidPlaceholders[0]}`;
|
||||
validationElement.innerHTML = `<i class="fas fa-times"></i> ${translate('settings.downloadPathTemplates.validation.invalidPlaceholder', { placeholder: invalidPlaceholders[0] }, `Invalid placeholder: ${invalidPlaceholders[0]}`)}`;
|
||||
validationElement.classList.add('invalid');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Template is valid
|
||||
validationElement.innerHTML = '<i class="fas fa-check"></i> Valid template';
|
||||
validationElement.innerHTML = `<i class="fas fa-check"></i> ${translate('settings.downloadPathTemplates.validation.validTemplate', {}, 'Valid template')}`;
|
||||
validationElement.classList.add('valid');
|
||||
return true;
|
||||
}
|
||||
@@ -737,11 +748,11 @@ export class SettingsManager {
|
||||
throw new Error('Failed to save download path templates');
|
||||
}
|
||||
|
||||
showToast('Download path templates updated', 'success');
|
||||
showToast('toast.settings.downloadTemplatesUpdated', {}, 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving download path templates:', error);
|
||||
showToast('Failed to save download path templates: ' + error.message, 'error');
|
||||
showToast('toast.settings.downloadTemplatesFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -802,7 +813,7 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success');
|
||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||
|
||||
// Apply frontend settings immediately
|
||||
this.applyFrontendSettings();
|
||||
@@ -823,11 +834,13 @@ export class SettingsManager {
|
||||
// Recalculate layout when compact mode changes
|
||||
if (settingKey === 'compact_mode' && state.virtualScroller) {
|
||||
state.virtualScroller.calculateLayout();
|
||||
showToast(`Compact Mode ${value ? 'enabled' : 'disabled'}`, 'success');
|
||||
showToast('toast.settings.compactModeToggled', {
|
||||
state: value ? 'toast.settings.compactModeEnabled' : 'toast.settings.compactModeDisabled'
|
||||
}, 'success');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
showToast('Failed to save setting: ' + error.message, 'error');
|
||||
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -881,7 +894,7 @@ export class SettingsManager {
|
||||
throw new Error('Failed to save setting');
|
||||
}
|
||||
|
||||
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success');
|
||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||
}
|
||||
|
||||
// Apply frontend settings immediately
|
||||
@@ -895,11 +908,11 @@ export class SettingsManager {
|
||||
if (value === 'medium') densityName = "Medium";
|
||||
if (value === 'compact') densityName = "Compact";
|
||||
|
||||
showToast(`Display Density set to ${densityName}`, 'success');
|
||||
showToast('toast.settings.displayDensitySet', { density: densityName }, 'success');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
showToast('Failed to save setting: ' + error.message, 'error');
|
||||
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -938,10 +951,46 @@ export class SettingsManager {
|
||||
throw new Error('Failed to save setting');
|
||||
}
|
||||
|
||||
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success');
|
||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||
|
||||
} catch (error) {
|
||||
showToast('Failed to save setting: ' + error.message, 'error');
|
||||
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async saveLanguageSetting() {
|
||||
const element = document.getElementById('languageSelect');
|
||||
if (!element) return;
|
||||
|
||||
const selectedLanguage = element.value;
|
||||
|
||||
try {
|
||||
// Update local state
|
||||
state.global.settings.language = selectedLanguage;
|
||||
|
||||
// Save to localStorage
|
||||
setStorageItem('settings', state.global.settings);
|
||||
|
||||
// Save to backend
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
language: selectedLanguage
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save language setting to backend');
|
||||
}
|
||||
|
||||
// Reload the page to apply the new language
|
||||
window.location.reload();
|
||||
|
||||
} catch (error) {
|
||||
showToast('toast.settings.languageChangeFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -976,15 +1025,15 @@ export class SettingsManager {
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('Cache files have been cleared successfully. Cache will rebuild on next action.', 'success');
|
||||
showToast('toast.settings.cacheCleared', {}, 'success');
|
||||
} else {
|
||||
showToast(`Failed to clear cache: ${result.error}`, 'error');
|
||||
showToast('toast.settings.cacheClearFailed', { error: result.error }, 'error');
|
||||
}
|
||||
|
||||
// Close the confirmation modal
|
||||
modalManager.closeModal('clearCacheModal');
|
||||
} catch (error) {
|
||||
showToast(`Error clearing cache: ${error.message}`, 'error');
|
||||
showToast('toast.settings.cacheClearError', { message: error.message }, 'error');
|
||||
modalManager.closeModal('clearCacheModal');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
resetDismissedBanner
|
||||
} from '../utils/storageHelpers.js';
|
||||
import { bannerService } from './BannerService.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
|
||||
export class UpdateService {
|
||||
constructor() {
|
||||
@@ -165,8 +166,8 @@ export class UpdateService {
|
||||
|
||||
if (updateToggle) {
|
||||
updateToggle.title = this.updateNotificationsEnabled && this.updateAvailable
|
||||
? "Update Available"
|
||||
: "Check Updates";
|
||||
? translate('update.updateAvailable')
|
||||
: translate('update.title');
|
||||
}
|
||||
|
||||
// Force updating badges visibility based on current state
|
||||
@@ -185,7 +186,9 @@ export class UpdateService {
|
||||
// Update title based on update availability
|
||||
const headerTitle = modal.querySelector('.update-header h2');
|
||||
if (headerTitle) {
|
||||
headerTitle.textContent = this.updateAvailable ? "Update Available" : "Check for Updates";
|
||||
headerTitle.textContent = this.updateAvailable ?
|
||||
translate('update.updateAvailable') :
|
||||
translate('update.title');
|
||||
}
|
||||
|
||||
// Always update version information, even if updateInfo is null
|
||||
@@ -209,9 +212,9 @@ export class UpdateService {
|
||||
const gitInfoEl = modal.querySelector('.git-info');
|
||||
if (gitInfoEl && this.gitInfo) {
|
||||
if (this.gitInfo.short_hash !== 'unknown') {
|
||||
let gitText = `Commit: ${this.gitInfo.short_hash}`;
|
||||
let gitText = `${translate('update.commit')}: ${this.gitInfo.short_hash}`;
|
||||
if (this.gitInfo.commit_date !== 'unknown') {
|
||||
gitText += ` - Date: ${this.gitInfo.commit_date}`;
|
||||
gitText += ` - ${translate('common.status.date', {}, 'Date')}: ${this.gitInfo.commit_date}`;
|
||||
}
|
||||
gitInfoEl.textContent = gitText;
|
||||
gitInfoEl.style.display = 'block';
|
||||
@@ -231,7 +234,7 @@ export class UpdateService {
|
||||
changelogItem.className = 'changelog-item';
|
||||
|
||||
const versionHeader = document.createElement('h4');
|
||||
versionHeader.textContent = `Version ${this.latestVersion}`;
|
||||
versionHeader.textContent = `${translate('common.status.version', {}, 'Version')} ${this.latestVersion}`;
|
||||
changelogItem.appendChild(versionHeader);
|
||||
|
||||
// Create changelog list
|
||||
@@ -247,7 +250,7 @@ export class UpdateService {
|
||||
} else {
|
||||
// If no changelog items available
|
||||
const listItem = document.createElement('li');
|
||||
listItem.textContent = "No detailed changelog available. Check GitHub for more information.";
|
||||
listItem.textContent = translate('update.noChangelogAvailable', {}, 'No detailed changelog available. Check GitHub for more information.');
|
||||
changelogList.appendChild(listItem);
|
||||
}
|
||||
|
||||
@@ -271,11 +274,11 @@ export class UpdateService {
|
||||
|
||||
try {
|
||||
this.isUpdating = true;
|
||||
this.updateUpdateUI('updating', 'Updating...');
|
||||
this.updateUpdateUI('updating', translate('update.status.updating'));
|
||||
this.showUpdateProgress(true);
|
||||
|
||||
// Update progress
|
||||
this.updateProgress(10, 'Preparing update...');
|
||||
this.updateProgress(10, translate('update.updateProgress.preparing'));
|
||||
|
||||
const response = await fetch('/api/perform-update', {
|
||||
method: 'POST',
|
||||
@@ -287,13 +290,13 @@ export class UpdateService {
|
||||
})
|
||||
});
|
||||
|
||||
this.updateProgress(50, 'Installing update...');
|
||||
this.updateProgress(50, translate('update.updateProgress.installing'));
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.updateProgress(100, 'Update completed successfully!');
|
||||
this.updateUpdateUI('success', 'Updated!');
|
||||
this.updateProgress(100, translate('update.updateProgress.completed'));
|
||||
this.updateUpdateUI('success', translate('update.status.updated'));
|
||||
|
||||
// Show success message and suggest restart
|
||||
setTimeout(() => {
|
||||
@@ -301,13 +304,13 @@ export class UpdateService {
|
||||
}, 1000);
|
||||
|
||||
} else {
|
||||
throw new Error(data.error || 'Update failed');
|
||||
throw new Error(data.error || translate('update.status.updateFailed'));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Update failed:', error);
|
||||
this.updateUpdateUI('error', 'Update Failed');
|
||||
this.updateProgress(0, `Update failed: ${error.message}`);
|
||||
this.updateUpdateUI('error', translate('update.status.updateFailed'));
|
||||
this.updateProgress(0, translate('update.updateProgress.failed', { error: error.message }));
|
||||
|
||||
// Hide progress after error
|
||||
setTimeout(() => {
|
||||
@@ -369,11 +372,11 @@ export class UpdateService {
|
||||
progressText.innerHTML = `
|
||||
<div style="text-align: center; color: var(--lora-success);">
|
||||
<i class="fas fa-check-circle" style="margin-right: 8px;"></i>
|
||||
Successfully updated to ${newVersion}!
|
||||
${translate('update.completion.successMessage', { version: newVersion })}
|
||||
<br><br>
|
||||
<div style="opacity: 0.95; color: var(--lora-error); font-size: 1em;">
|
||||
Please restart ComfyUI or LoRA Manager to apply update.<br>
|
||||
Make sure to reload your browser for both LoRA Manager and ComfyUI.
|
||||
${translate('update.completion.restartMessage')}<br>
|
||||
${translate('update.completion.reloadMessage')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -470,16 +473,19 @@ export class UpdateService {
|
||||
|
||||
registerVersionMismatchBanner() {
|
||||
// Get stored and current version for display
|
||||
const storedVersion = getStoredVersionInfo() || 'unknown';
|
||||
const currentVersion = this.currentVersionInfo || 'unknown';
|
||||
const storedVersion = getStoredVersionInfo() || translate('common.status.unknown');
|
||||
const currentVersion = this.currentVersionInfo || translate('common.status.unknown');
|
||||
|
||||
bannerService.registerBanner('version-mismatch', {
|
||||
id: 'version-mismatch',
|
||||
title: 'Application Update Detected',
|
||||
content: `Your browser is running an outdated version of LoRA Manager (${storedVersion}). The server has been updated to version ${currentVersion}. Please refresh to ensure proper functionality.`,
|
||||
title: translate('banners.versionMismatch.title', {}, 'Application Update Detected'),
|
||||
content: translate('banners.versionMismatch.content', {
|
||||
storedVersion,
|
||||
currentVersion
|
||||
}, `Your browser is running an outdated version of LoRA Manager (${storedVersion}). The server has been updated to version ${currentVersion}. Please refresh to ensure proper functionality.`),
|
||||
actions: [
|
||||
{
|
||||
text: 'Refresh Now',
|
||||
text: translate('banners.versionMismatch.refreshNow', {}, 'Refresh Now'),
|
||||
icon: 'fas fa-sync',
|
||||
action: 'hardRefresh',
|
||||
type: 'primary'
|
||||
@@ -492,7 +498,7 @@ export class UpdateService {
|
||||
// Add countdown element
|
||||
const countdownEl = document.createElement('div');
|
||||
countdownEl.className = 'banner-countdown';
|
||||
countdownEl.innerHTML = `<span>Refreshing in <strong>15</strong> seconds...</span>`;
|
||||
countdownEl.innerHTML = `<span>${translate('banners.versionMismatch.refreshingIn', {}, 'Refreshing in')} <strong>15</strong> ${translate('banners.versionMismatch.seconds', {}, 'seconds')}...</span>`;
|
||||
bannerElement.querySelector('.banner-content').appendChild(countdownEl);
|
||||
|
||||
// Start countdown
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||
import { getStorageItem } from '../../utils/storageHelpers.js';
|
||||
@@ -13,13 +14,13 @@ export class DownloadManager {
|
||||
const isDownloadOnly = !!this.importManager.recipeId;
|
||||
|
||||
if (!isDownloadOnly && !this.importManager.recipeName) {
|
||||
showToast('Please enter a recipe name', 'error');
|
||||
showToast('toast.recipes.enterRecipeName', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Show progress indicator
|
||||
this.importManager.loadingManager.showSimpleLoading(isDownloadOnly ? 'Downloading LoRAs...' : 'Saving recipe...');
|
||||
this.importManager.loadingManager.showSimpleLoading(isDownloadOnly ? translate('recipes.controls.import.downloadingLoras', {}, 'Downloading LoRAs...') : translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...'));
|
||||
|
||||
// Only send the complete recipe to save if not in download-only mode
|
||||
if (!isDownloadOnly) {
|
||||
@@ -77,7 +78,7 @@ export class DownloadManager {
|
||||
if (!result.success) {
|
||||
// Handle save error
|
||||
console.error("Failed to save recipe:", result.error);
|
||||
showToast(result.error, 'error');
|
||||
showToast('toast.recipes.recipeSaveFailed', { error: result.error }, 'error');
|
||||
// Close modal
|
||||
modalManager.closeModal('importModal');
|
||||
return;
|
||||
@@ -93,10 +94,10 @@ export class DownloadManager {
|
||||
// Show success message
|
||||
if (isDownloadOnly) {
|
||||
if (failedDownloads === 0) {
|
||||
showToast('LoRAs downloaded successfully', 'success');
|
||||
showToast('toast.loras.downloadSuccessful', {}, 'success');
|
||||
}
|
||||
} else {
|
||||
showToast(`Recipe "${this.importManager.recipeName}" saved successfully`, 'success');
|
||||
showToast('toast.recipes.nameSaved', { name: this.importManager.recipeName }, 'success');
|
||||
}
|
||||
|
||||
// Close modal
|
||||
@@ -107,7 +108,7 @@ export class DownloadManager {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showToast(error.message, 'error');
|
||||
showToast('toast.recipes.processingError', { message: error.message }, 'error');
|
||||
} finally {
|
||||
this.importManager.loadingManager.hide();
|
||||
}
|
||||
@@ -117,7 +118,7 @@ export class DownloadManager {
|
||||
// For download, we need to validate the target path
|
||||
const loraRoot = document.getElementById('importLoraRoot')?.value;
|
||||
if (!loraRoot) {
|
||||
throw new Error('Please select a LoRA root directory');
|
||||
throw new Error(translate('recipes.controls.import.errors.selectLoraRoot', {}, 'Please select a LoRA root directory'));
|
||||
}
|
||||
|
||||
// Build target path
|
||||
@@ -195,7 +196,7 @@ export class DownloadManager {
|
||||
currentLoraProgress = 0;
|
||||
|
||||
// Initial status update for new LoRA
|
||||
this.importManager.loadingManager.setStatus(`Starting download for LoRA ${i+1}/${this.importManager.downloadableLoRAs.length}`);
|
||||
this.importManager.loadingManager.setStatus(translate('recipes.controls.import.startingDownload', { current: i+1, total: this.importManager.downloadableLoRAs.length }, `Starting download for LoRA ${i+1}/${this.importManager.downloadableLoRAs.length}`));
|
||||
updateProgress(0, completedDownloads, lora.name);
|
||||
|
||||
try {
|
||||
@@ -238,15 +239,19 @@ export class DownloadManager {
|
||||
|
||||
// Show appropriate completion message based on results
|
||||
if (failedDownloads === 0) {
|
||||
showToast(`All ${completedDownloads} LoRAs downloaded successfully`, 'success');
|
||||
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
||||
} else {
|
||||
if (accessFailures > 0) {
|
||||
showToast(
|
||||
`Downloaded ${completedDownloads} of ${this.importManager.downloadableLoRAs.length} LoRAs. ${accessFailures} failed due to access restrictions. Check your API key in settings or early access status.`,
|
||||
'error'
|
||||
);
|
||||
showToast('toast.loras.downloadPartialWithAccess', {
|
||||
completed: completedDownloads,
|
||||
total: this.importManager.downloadableLoRAs.length,
|
||||
accessFailures: accessFailures
|
||||
}, 'error');
|
||||
} else {
|
||||
showToast(`Downloaded ${completedDownloads} of ${this.importManager.downloadableLoRAs.length} LoRAs`, 'error');
|
||||
showToast('toast.loras.downloadPartialSuccess', {
|
||||
completed: completedDownloads,
|
||||
total: this.importManager.downloadableLoRAs.length
|
||||
}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { getStorageItem } from '../../utils/storageHelpers.js';
|
||||
|
||||
export class FolderBrowser {
|
||||
@@ -136,7 +137,7 @@ export class FolderBrowser {
|
||||
this.initializeFolderBrowser();
|
||||
} catch (error) {
|
||||
console.error('Error in API calls:', error);
|
||||
showToast(error.message, 'error');
|
||||
showToast('toast.recipes.folderBrowserError', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +205,7 @@ export class FolderBrowser {
|
||||
const loraRoot = document.getElementById('importLoraRoot')?.value || '';
|
||||
const newFolder = document.getElementById('importNewFolder')?.value?.trim() || '';
|
||||
|
||||
let fullPath = loraRoot || 'Select a LoRA root directory';
|
||||
let fullPath = loraRoot || translate('recipes.controls.import.selectLoraRoot', {}, 'Select a LoRA root directory');
|
||||
|
||||
if (loraRoot) {
|
||||
if (this.importManager.selectedFolder) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
|
||||
export class ImageProcessor {
|
||||
constructor(importManager) {
|
||||
@@ -13,7 +14,7 @@ export class ImageProcessor {
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.match('image.*')) {
|
||||
errorElement.textContent = 'Please select an image file';
|
||||
errorElement.textContent = translate('recipes.controls.import.errors.selectImageFile', {}, 'Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -32,7 +33,7 @@ export class ImageProcessor {
|
||||
|
||||
// Validate input
|
||||
if (!input) {
|
||||
errorElement.textContent = 'Please enter a URL or file path';
|
||||
errorElement.textContent = translate('recipes.controls.import.errors.enterUrlOrPath', {}, 'Please enter a URL or file path');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -40,7 +41,7 @@ export class ImageProcessor {
|
||||
errorElement.textContent = '';
|
||||
|
||||
// Show loading indicator
|
||||
this.importManager.loadingManager.showSimpleLoading('Processing input...');
|
||||
this.importManager.loadingManager.showSimpleLoading(translate('recipes.controls.import.processingInput', {}, 'Processing input...'));
|
||||
|
||||
try {
|
||||
// Check if it's a URL or a local file path
|
||||
@@ -156,12 +157,12 @@ export class ImageProcessor {
|
||||
|
||||
async uploadAndAnalyzeImage() {
|
||||
if (!this.importManager.recipeImage) {
|
||||
showToast('Please select an image first', 'error');
|
||||
showToast('toast.recipes.selectImageFirst', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.importManager.loadingManager.showSimpleLoading('Analyzing image metadata...');
|
||||
this.importManager.loadingManager.showSimpleLoading(translate('recipes.controls.import.analyzingMetadata', {}, 'Analyzing image metadata...'));
|
||||
|
||||
// Create form data for upload
|
||||
const formData = new FormData();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
|
||||
export class RecipeDataManager {
|
||||
constructor(importManager) {
|
||||
@@ -62,17 +63,17 @@ export class RecipeDataManager {
|
||||
// For file upload mode
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
imagePreview.innerHTML = `<img src="${e.target.result}" alt="Recipe preview">`;
|
||||
imagePreview.innerHTML = `<img src="${e.target.result}" alt="${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}">`;
|
||||
};
|
||||
reader.readAsDataURL(this.importManager.recipeImage);
|
||||
} else if (this.importManager.recipeData && this.importManager.recipeData.image_base64) {
|
||||
// For URL mode - use the base64 image data returned from the backend
|
||||
imagePreview.innerHTML = `<img src="data:image/jpeg;base64,${this.importManager.recipeData.image_base64}" alt="Recipe preview">`;
|
||||
imagePreview.innerHTML = `<img src="data:image/jpeg;base64,${this.importManager.recipeData.image_base64}" alt="${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}">`;
|
||||
} else if (this.importManager.importMode === 'url') {
|
||||
// Fallback for URL mode if no base64 data
|
||||
const urlInput = document.getElementById('imageUrlInput');
|
||||
if (urlInput && urlInput.value) {
|
||||
imagePreview.innerHTML = `<img src="${urlInput.value}" alt="Recipe preview" crossorigin="anonymous">`;
|
||||
imagePreview.innerHTML = `<img src="${urlInput.value}" alt="${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}" crossorigin="anonymous">`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,7 +83,7 @@ export class RecipeDataManager {
|
||||
const existingLoras = this.importManager.recipeData.loras.filter(lora => lora.existsLocally).length;
|
||||
const loraCountInfo = document.getElementById('loraCountInfo');
|
||||
if (loraCountInfo) {
|
||||
loraCountInfo.textContent = `(${existingLoras}/${totalLoras} in library)`;
|
||||
loraCountInfo.textContent = translate('recipes.controls.import.loraCountInfo', { existing: existingLoras, total: totalLoras }, `(${existingLoras}/${totalLoras} in library)`);
|
||||
}
|
||||
|
||||
// Display LoRAs list
|
||||
@@ -98,16 +99,16 @@ export class RecipeDataManager {
|
||||
let statusBadge;
|
||||
if (isDeleted) {
|
||||
statusBadge = `<div class="deleted-badge">
|
||||
<i class="fas fa-exclamation-circle"></i> Deleted from Civitai
|
||||
<i class="fas fa-exclamation-circle"></i> ${translate('recipes.controls.import.deletedFromCivitai', {}, 'Deleted from Civitai')}
|
||||
</div>`;
|
||||
} else {
|
||||
statusBadge = existsLocally ?
|
||||
`<div class="local-badge">
|
||||
<i class="fas fa-check"></i> In Library
|
||||
<i class="fas fa-check"></i> ${translate('recipes.controls.import.inLibrary', {}, 'In Library')}
|
||||
<div class="local-path">${localPath}</div>
|
||||
</div>` :
|
||||
`<div class="missing-badge">
|
||||
<i class="fas fa-exclamation-triangle"></i> Not in Library
|
||||
<i class="fas fa-exclamation-triangle"></i> ${translate('recipes.controls.import.notInLibrary', {}, 'Not in Library')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -115,20 +116,20 @@ export class RecipeDataManager {
|
||||
let earlyAccessBadge = '';
|
||||
if (isEarlyAccess) {
|
||||
// Format the early access end date if available
|
||||
let earlyAccessInfo = 'This LoRA requires early access payment to download.';
|
||||
let earlyAccessInfo = translate('recipes.controls.import.earlyAccessRequired', {}, 'This LoRA requires early access payment to download.');
|
||||
if (lora.earlyAccessEndsAt) {
|
||||
try {
|
||||
const endDate = new Date(lora.earlyAccessEndsAt);
|
||||
const formattedDate = endDate.toLocaleDateString();
|
||||
earlyAccessInfo += ` Early access ends on ${formattedDate}.`;
|
||||
earlyAccessInfo += ` ${translate('recipes.controls.import.earlyAccessEnds', { date: formattedDate }, `Early access ends on ${formattedDate}.`)}`;
|
||||
} catch (e) {
|
||||
console.warn('Failed to format early access date', e);
|
||||
}
|
||||
}
|
||||
|
||||
earlyAccessBadge = `<div class="early-access-badge">
|
||||
<i class="fas fa-clock"></i> Early Access
|
||||
<div class="early-access-info">${earlyAccessInfo} Verify that you have purchased early access before downloading.</div>
|
||||
<i class="fas fa-clock"></i> ${translate('recipes.controls.import.earlyAccess', {}, 'Early Access')}
|
||||
<div class="early-access-info">${earlyAccessInfo} ${translate('recipes.controls.import.verifyEarlyAccess', {}, 'Verify that you have purchased early access before downloading.')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -139,7 +140,7 @@ export class RecipeDataManager {
|
||||
return `
|
||||
<div class="lora-item ${existsLocally ? 'exists-locally' : isDeleted ? 'is-deleted' : 'missing-locally'} ${isEarlyAccess ? 'is-early-access' : ''}">
|
||||
<div class="lora-thumbnail">
|
||||
<img src="${lora.thumbnailUrl || '/loras_static/images/no-preview.png'}" alt="LoRA preview">
|
||||
<img src="${lora.thumbnailUrl || '/loras_static/images/no-preview.png'}" alt="${translate('recipes.controls.import.loraPreviewAlt', {}, 'LoRA preview')}">
|
||||
</div>
|
||||
<div class="lora-content">
|
||||
<div class="lora-header">
|
||||
@@ -232,12 +233,12 @@ export class RecipeDataManager {
|
||||
<div class="warning-icon"><i class="fas fa-clone"></i></div>
|
||||
<div class="warning-content">
|
||||
<div class="warning-title">
|
||||
${this.importManager.duplicateRecipes.length} identical ${this.importManager.duplicateRecipes.length === 1 ? 'recipe' : 'recipes'} found in your library
|
||||
${translate('recipes.controls.import.duplicateRecipesFound', { count: this.importManager.duplicateRecipes.length }, `${this.importManager.duplicateRecipes.length} identical ${this.importManager.duplicateRecipes.length === 1 ? 'recipe' : 'recipes'} found in your library`)}
|
||||
</div>
|
||||
<div class="warning-text">
|
||||
These recipes contain the same LoRAs with identical weights.
|
||||
${translate('recipes.controls.import.duplicateRecipesDescription', {}, 'These recipes contain the same LoRAs with identical weights.')}
|
||||
<button id="toggleDuplicatesList" class="toggle-duplicates-btn">
|
||||
Show duplicates <i class="fas fa-chevron-down"></i>
|
||||
${translate('recipes.controls.import.showDuplicates', {}, 'Show duplicates')} <i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -246,7 +247,7 @@ export class RecipeDataManager {
|
||||
${this.importManager.duplicateRecipes.map((recipe) => `
|
||||
<div class="duplicate-recipe-card">
|
||||
<div class="duplicate-recipe-preview">
|
||||
<img src="${recipe.file_url}" alt="Recipe preview">
|
||||
<img src="${recipe.file_url}" alt="${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}">
|
||||
<div class="duplicate-recipe-title">${recipe.title}</div>
|
||||
</div>
|
||||
<div class="duplicate-recipe-details">
|
||||
@@ -254,7 +255,7 @@ export class RecipeDataManager {
|
||||
<i class="fas fa-calendar-alt"></i> ${formatDate(recipe.modified)}
|
||||
</div>
|
||||
<div class="duplicate-recipe-lora-count">
|
||||
<i class="fas fa-layer-group"></i> ${recipe.lora_count} LoRAs
|
||||
<i class="fas fa-layer-group"></i> ${translate('recipes.controls.import.loraCount', { count: recipe.lora_count }, `${recipe.lora_count} LoRAs`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,9 +276,9 @@ export class RecipeDataManager {
|
||||
const icon = toggleButton.querySelector('i');
|
||||
if (icon) {
|
||||
if (list.classList.contains('collapsed')) {
|
||||
toggleButton.innerHTML = `Show duplicates <i class="fas fa-chevron-down"></i>`;
|
||||
toggleButton.innerHTML = `${translate('recipes.controls.import.showDuplicates', {}, 'Show duplicates')} <i class="fas fa-chevron-down"></i>`;
|
||||
} else {
|
||||
toggleButton.innerHTML = `Hide duplicates <i class="fas fa-chevron-up"></i>`;
|
||||
toggleButton.innerHTML = `${translate('recipes.controls.import.hideDuplicates', {}, 'Hide duplicates')} <i class="fas fa-chevron-up"></i>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,9 +363,9 @@ export class RecipeDataManager {
|
||||
nextButton.classList.remove('warning-btn');
|
||||
|
||||
if (missingNotDeleted > 0) {
|
||||
nextButton.textContent = 'Download Missing LoRAs';
|
||||
nextButton.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs');
|
||||
} else {
|
||||
nextButton.textContent = 'Save Recipe';
|
||||
nextButton.textContent = translate('recipes.controls.import.saveRecipe', {}, 'Save Recipe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,7 +392,7 @@ export class RecipeDataManager {
|
||||
const tagsContainer = document.getElementById('tagsContainer');
|
||||
|
||||
if (this.importManager.recipeTags.length === 0) {
|
||||
tagsContainer.innerHTML = '<div class="empty-tags">No tags added</div>';
|
||||
tagsContainer.innerHTML = `<div class="empty-tags">${translate('recipes.controls.import.noTagsAdded', {}, 'No tags added')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -406,7 +407,7 @@ export class RecipeDataManager {
|
||||
proceedFromDetails() {
|
||||
// Validate recipe name
|
||||
if (!this.importManager.recipeName) {
|
||||
showToast('Please enter a recipe name', 'error');
|
||||
showToast('toast.recipes.enterRecipeName', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { appCore } from './core.js';
|
||||
import { ImportManager } from './managers/ImportManager.js';
|
||||
import { RecipeModal } from './components/RecipeModal.js';
|
||||
import { getCurrentPageState, state } from './state/index.js';
|
||||
import { getCurrentPageState } from './state/index.js';
|
||||
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||
|
||||
@@ -29,7 +29,7 @@ export const state = {
|
||||
isLoading: false,
|
||||
hasMore: true,
|
||||
sortBy: 'name',
|
||||
activeFolder: null,
|
||||
activeFolder: getStorageItem(`${MODEL_TYPES.LORA}_activeFolder`),
|
||||
activeLetterFilter: null,
|
||||
previewVersions: loraPreviewVersions,
|
||||
searchManager: null,
|
||||
@@ -38,7 +38,7 @@ export const state = {
|
||||
modelname: true,
|
||||
tags: false,
|
||||
creator: false,
|
||||
recursive: false
|
||||
recursive: true,
|
||||
},
|
||||
filters: {
|
||||
baseModel: [],
|
||||
@@ -78,14 +78,14 @@ export const state = {
|
||||
isLoading: false,
|
||||
hasMore: true,
|
||||
sortBy: 'name',
|
||||
activeFolder: null,
|
||||
activeFolder: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_activeFolder`),
|
||||
previewVersions: checkpointPreviewVersions,
|
||||
searchManager: null,
|
||||
searchOptions: {
|
||||
filename: true,
|
||||
modelname: true,
|
||||
creator: false,
|
||||
recursive: false
|
||||
recursive: true,
|
||||
},
|
||||
filters: {
|
||||
baseModel: [],
|
||||
@@ -104,7 +104,7 @@ export const state = {
|
||||
isLoading: false,
|
||||
hasMore: true,
|
||||
sortBy: 'name',
|
||||
activeFolder: null,
|
||||
activeFolder: getStorageItem(`${MODEL_TYPES.EMBEDDING}_activeFolder`),
|
||||
activeLetterFilter: null,
|
||||
previewVersions: embeddingPreviewVersions,
|
||||
searchManager: null,
|
||||
@@ -113,7 +113,7 @@ export const state = {
|
||||
modelname: true,
|
||||
tags: false,
|
||||
creator: false,
|
||||
recursive: false
|
||||
recursive: true,
|
||||
},
|
||||
filters: {
|
||||
baseModel: [],
|
||||
|
||||
@@ -85,7 +85,7 @@ class StatisticsManager {
|
||||
console.log('Statistics data loaded:', this.data);
|
||||
} catch (error) {
|
||||
console.error('Error loading statistics data:', error);
|
||||
showToast('Failed to load statistics data', 'error');
|
||||
showToast('toast.general.statisticsLoadFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
156
static/js/test/i18nTest.js
Normal file
156
static/js/test/i18nTest.js
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* i18n System Test
|
||||
* Simple test to verify internationalization functionality
|
||||
*/
|
||||
|
||||
import { i18n } from '../i18n/index.js';
|
||||
import { initializePageI18n, t, formatFileSize, formatDate, formatNumber } from '../utils/i18nHelpers.js';
|
||||
import { findUnusedTranslationKeys, findMissingTranslationKeys, extractLeafKeys } from '../i18n/validator.js';
|
||||
|
||||
// Mock DOM elements for testing
|
||||
function createMockDOM() {
|
||||
// Create a test container
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = `
|
||||
<div data-i18n="header.appTitle">LoRA Manager</div>
|
||||
<input data-i18n="header.search.placeholder" data-i18n-target="placeholder" placeholder="Search..." />
|
||||
<button data-i18n="common.actions.save">Save</button>
|
||||
<span data-i18n="loras.bulkOperations.selected" data-i18n-params='{"count": 5}'>5 selected</span>
|
||||
`;
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
// Test basic translation functionality
|
||||
function testBasicTranslation() {
|
||||
console.log('=== Testing Basic Translation ===');
|
||||
|
||||
// Test simple translation
|
||||
const saveText = t('common.actions.save');
|
||||
console.log(`Save button text: ${saveText}`);
|
||||
|
||||
// Test translation with parameters
|
||||
const selectedText = t('loras.bulkOperations.selected', { count: 3 });
|
||||
console.log(`Selection text: ${selectedText}`);
|
||||
|
||||
// Test non-existent key (should return the key itself)
|
||||
const missingKey = t('non.existent.key');
|
||||
console.log(`Missing key: ${missingKey}`);
|
||||
}
|
||||
|
||||
// Test DOM translation
|
||||
function testDOMTranslation() {
|
||||
console.log('=== Testing DOM Translation ===');
|
||||
|
||||
const container = createMockDOM();
|
||||
|
||||
// Apply translations
|
||||
initializePageI18n();
|
||||
|
||||
// Check if translations were applied
|
||||
const titleElement = container.querySelector('[data-i18n="header.appTitle"]');
|
||||
const inputElement = container.querySelector('input[data-i18n="header.search.placeholder"]');
|
||||
const buttonElement = container.querySelector('[data-i18n="common.actions.save"]');
|
||||
|
||||
console.log(`Title: ${titleElement.textContent}`);
|
||||
console.log(`Input placeholder: ${inputElement.placeholder}`);
|
||||
console.log(`Button: ${buttonElement.textContent}`);
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(container);
|
||||
}
|
||||
|
||||
// Test formatting functions
|
||||
function testFormatting() {
|
||||
console.log('=== Testing Formatting Functions ===');
|
||||
|
||||
// Test file size formatting
|
||||
const sizes = [0, 1024, 1048576, 1073741824];
|
||||
sizes.forEach(size => {
|
||||
const formatted = formatFileSize(size);
|
||||
console.log(`${size} bytes = ${formatted}`);
|
||||
});
|
||||
|
||||
// Test date formatting
|
||||
const date = new Date('2024-01-15T10:30:00');
|
||||
const formattedDate = formatDate(date, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
console.log(`Date: ${formattedDate}`);
|
||||
|
||||
// Test number formatting
|
||||
const number = 1234.567;
|
||||
const formattedNumber = formatNumber(number, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
console.log(`Number: ${formattedNumber}`);
|
||||
}
|
||||
|
||||
// Test language detection
|
||||
function testLanguageDetection() {
|
||||
console.log('=== Testing Language Detection ===');
|
||||
console.log(`Detected language: ${i18n.getCurrentLocale()}`);
|
||||
console.log(`Is RTL: ${i18n.isRTL()}`);
|
||||
console.log(`Browser language: ${navigator.language}`);
|
||||
}
|
||||
|
||||
// Test unused translations detection
|
||||
function testUnusedTranslationsDetection() {
|
||||
console.log('=== Testing Unused Translations Detection ===');
|
||||
|
||||
// Mock used keys
|
||||
const mockUsedKeys = [
|
||||
'common.actions.save',
|
||||
'common.actions.cancel',
|
||||
'header.appTitle'
|
||||
];
|
||||
|
||||
// Get all translations
|
||||
const allTranslations = i18n.getTranslations();
|
||||
|
||||
// Find unused keys (only considering leaf nodes)
|
||||
const unusedKeys = findUnusedTranslationKeys(allTranslations, mockUsedKeys);
|
||||
|
||||
console.log(`Found ${unusedKeys.length} unused translation keys`);
|
||||
console.log('First 5 unused keys:', unusedKeys.slice(0, 5));
|
||||
|
||||
// Find missing keys
|
||||
const missingKeys = findMissingTranslationKeys(allTranslations, [
|
||||
...mockUsedKeys,
|
||||
'non.existent.key'
|
||||
]);
|
||||
|
||||
console.log(`Found ${missingKeys.length} missing translation keys:`, missingKeys);
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
function runTests() {
|
||||
console.log('Starting i18n System Tests...');
|
||||
console.log('=====================================');
|
||||
|
||||
testLanguageDetection();
|
||||
testBasicTranslation();
|
||||
testFormatting();
|
||||
|
||||
// Only test DOM if we're in a browser environment
|
||||
if (typeof document !== 'undefined') {
|
||||
testDOMTranslation();
|
||||
}
|
||||
|
||||
// Test unused translations detection
|
||||
testUnusedTranslationsDetection();
|
||||
|
||||
console.log('=====================================');
|
||||
console.log('i18n System Tests Completed!');
|
||||
}
|
||||
|
||||
// Export for manual testing
|
||||
export { runTests };
|
||||
|
||||
// Auto-run tests if this module is loaded directly
|
||||
if (typeof window !== 'undefined' && window.location.search.includes('test=i18n')) {
|
||||
document.addEventListener('DOMContentLoaded', runTests);
|
||||
}
|
||||
@@ -117,7 +117,7 @@ export class VirtualScroller {
|
||||
maxColumns = 10;
|
||||
}
|
||||
maxGridWidth = 2400; // Match exact CSS container width for 4K
|
||||
} else if (window.innerWidth >= 2000) { // 2K/1440p
|
||||
} else if (window.innerWidth >= 2150) { // 2K/1440p
|
||||
if (displayDensity === 'default') {
|
||||
maxColumns = 6;
|
||||
} else if (displayDensity === 'medium') {
|
||||
@@ -210,7 +210,7 @@ export class VirtualScroller {
|
||||
this.scheduleRender();
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize virtual scroller:', err);
|
||||
showToast('Failed to load items', 'error');
|
||||
showToast('toast.virtual.loadFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +293,7 @@ export class VirtualScroller {
|
||||
return items;
|
||||
} catch (err) {
|
||||
console.error('Failed to load more items:', err);
|
||||
showToast('Failed to load more items', 'error');
|
||||
showToast('toast.virtual.loadMoreFailed', {}, 'error');
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
pageState.isLoading = false;
|
||||
@@ -571,7 +571,7 @@ export class VirtualScroller {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data window:', err);
|
||||
showToast('Failed to load items at this position', 'error');
|
||||
showToast('toast.virtual.loadPositionFailed', {}, 'error');
|
||||
} finally {
|
||||
this.fetchingWindow = false;
|
||||
}
|
||||
@@ -779,27 +779,37 @@ export class VirtualScroller {
|
||||
console.log('Virtual scroller enabled');
|
||||
}
|
||||
|
||||
// Helper function for deep merging objects
|
||||
// Helper function for deep merging objects - only updates existing keys in target
|
||||
deepMerge(target, source) {
|
||||
if (!source) return target;
|
||||
|
||||
if (!source || !target) return target;
|
||||
|
||||
const result = { ...target };
|
||||
|
||||
Object.keys(source).forEach(key => {
|
||||
if (source[key] !== null && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||
// If property exists in target and is an object, recursively merge
|
||||
if (target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) {
|
||||
result[key] = this.deepMerge(target[key], source[key]);
|
||||
|
||||
// Only iterate over keys that exist in target
|
||||
Object.keys(target).forEach(key => {
|
||||
// Check if source has this key
|
||||
if (source.hasOwnProperty(key)) {
|
||||
const targetValue = target[key];
|
||||
const sourceValue = source[key];
|
||||
|
||||
// If both values are non-null objects and not arrays, merge recursively
|
||||
if (
|
||||
targetValue !== null &&
|
||||
typeof targetValue === 'object' &&
|
||||
!Array.isArray(targetValue) &&
|
||||
sourceValue !== null &&
|
||||
typeof sourceValue === 'object' &&
|
||||
!Array.isArray(sourceValue)
|
||||
) {
|
||||
result[key] = this.deepMerge(targetValue, sourceValue);
|
||||
} else {
|
||||
// Otherwise just assign the source value
|
||||
result[key] = source[key];
|
||||
// For primitive types, arrays, or null, use the value from source
|
||||
result[key] = sourceValue;
|
||||
}
|
||||
} else {
|
||||
// For non-objects (including arrays), just assign the value
|
||||
result[key] = source[key];
|
||||
}
|
||||
// If source does not have this key, keep the original value from target
|
||||
});
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export const BASE_MODELS = {
|
||||
// Other models
|
||||
FLUX_1_D: "Flux.1 D",
|
||||
FLUX_1_S: "Flux.1 S",
|
||||
FLUX_1_KREA: "Flux.1 Krea",
|
||||
FLUX_1_KONTEXT: "Flux.1 Kontext",
|
||||
AURAFLOW: "AuraFlow",
|
||||
PIXART_A: "PixArt a",
|
||||
@@ -53,56 +54,6 @@ export const BASE_MODELS = {
|
||||
UNKNOWN: "Other"
|
||||
};
|
||||
|
||||
// Base model display names and their corresponding class names (for styling)
|
||||
export const BASE_MODEL_CLASSES = {
|
||||
// Stable Diffusion 1.x models
|
||||
[BASE_MODELS.SD_1_4]: "sd-1-4",
|
||||
[BASE_MODELS.SD_1_5]: "sd-1-5",
|
||||
[BASE_MODELS.SD_1_5_LCM]: "sd-1-5-lcm",
|
||||
[BASE_MODELS.SD_1_5_HYPER]: "sd-1-5-hyper",
|
||||
|
||||
// Stable Diffusion 2.x models
|
||||
[BASE_MODELS.SD_2_0]: "sd-2-0",
|
||||
[BASE_MODELS.SD_2_1]: "sd-2-1",
|
||||
|
||||
// Stable Diffusion 3.x models
|
||||
[BASE_MODELS.SD_3]: "sd-3",
|
||||
[BASE_MODELS.SD_3_5]: "sd-3-5",
|
||||
[BASE_MODELS.SD_3_5_MEDIUM]: "sd-3-5-medium",
|
||||
[BASE_MODELS.SD_3_5_LARGE]: "sd-3-5-large",
|
||||
[BASE_MODELS.SD_3_5_LARGE_TURBO]: "sd-3-5-large-turbo",
|
||||
|
||||
// SDXL models
|
||||
[BASE_MODELS.SDXL]: "sdxl",
|
||||
[BASE_MODELS.SDXL_LIGHTNING]: "sdxl-lightning",
|
||||
[BASE_MODELS.SDXL_HYPER]: "sdxl-hyper",
|
||||
|
||||
// Video models
|
||||
[BASE_MODELS.SVD]: "svd",
|
||||
[BASE_MODELS.LTXV]: "ltxv",
|
||||
[BASE_MODELS.WAN_VIDEO]: "wan-video",
|
||||
[BASE_MODELS.HUNYUAN_VIDEO]: "hunyuan-video",
|
||||
|
||||
// Other models
|
||||
[BASE_MODELS.FLUX_1_D]: "flux-d",
|
||||
[BASE_MODELS.FLUX_1_S]: "flux-s",
|
||||
[BASE_MODELS.FLUX_1_KONTEXT]: "flux-kontext",
|
||||
[BASE_MODELS.AURAFLOW]: "auraflow",
|
||||
[BASE_MODELS.PIXART_A]: "pixart-a",
|
||||
[BASE_MODELS.PIXART_E]: "pixart-e",
|
||||
[BASE_MODELS.HUNYUAN_1]: "hunyuan-1",
|
||||
[BASE_MODELS.LUMINA]: "lumina",
|
||||
[BASE_MODELS.KOLORS]: "kolors",
|
||||
[BASE_MODELS.NOOBAI]: "noobai",
|
||||
[BASE_MODELS.ILLUSTRIOUS]: "il",
|
||||
[BASE_MODELS.PONY]: "pony",
|
||||
[BASE_MODELS.HIDREAM]: "hidream",
|
||||
[BASE_MODELS.QWEN]: "qwen",
|
||||
|
||||
// Default
|
||||
[BASE_MODELS.UNKNOWN]: "unknown"
|
||||
};
|
||||
|
||||
// Path template constants for download organization
|
||||
export const DOWNLOAD_PATH_TEMPLATES = {
|
||||
FLAT: {
|
||||
|
||||
109
static/js/utils/i18nHelpers.js
Normal file
109
static/js/utils/i18nHelpers.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* i18n utility functions for safe translation handling
|
||||
*/
|
||||
|
||||
/**
|
||||
* Synchronous translation function.
|
||||
* Assumes window.i18n is ready.
|
||||
* @param {string} key - Translation key
|
||||
* @param {Object} params - Parameters for interpolation
|
||||
* @param {string} fallback - Fallback text if translation fails
|
||||
* @returns {string} Translated text
|
||||
*/
|
||||
export function translate(key, params = {}, fallback = null) {
|
||||
if (!window.i18n) {
|
||||
console.warn('i18n not available');
|
||||
return fallback || key;
|
||||
}
|
||||
const translation = window.i18n.t(key, params);
|
||||
if (translation === key && fallback) {
|
||||
return fallback;
|
||||
}
|
||||
return translation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update element text with translation
|
||||
* @param {HTMLElement|string} element - Element or selector
|
||||
* @param {string} key - Translation key
|
||||
* @param {Object} params - Parameters for interpolation
|
||||
* @param {string} fallback - Fallback text
|
||||
*/
|
||||
export function updateElementText(element, key, params = {}, fallback = null) {
|
||||
const el = typeof element === 'string' ? document.querySelector(element) : element;
|
||||
if (!el) return;
|
||||
|
||||
const text = translate(key, params, fallback);
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update element attribute with translation
|
||||
* @param {HTMLElement|string} element - Element or selector
|
||||
* @param {string} attribute - Attribute name (e.g., 'title', 'placeholder')
|
||||
* @param {string} key - Translation key
|
||||
* @param {Object} params - Parameters for interpolation
|
||||
* @param {string} fallback - Fallback text
|
||||
*/
|
||||
export function updateElementAttribute(element, attribute, key, params = {}, fallback = null) {
|
||||
const el = typeof element === 'string' ? document.querySelector(element) : element;
|
||||
if (!el) return;
|
||||
|
||||
const text = translate(key, params, fallback);
|
||||
el.setAttribute(attribute, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reactive translation that updates when language changes
|
||||
* @param {string} key - Translation key
|
||||
* @param {Object} params - Parameters for interpolation
|
||||
* @param {Function} callback - Callback function to call with translated text
|
||||
*/
|
||||
export function createReactiveTranslation(key, params = {}, callback) {
|
||||
let currentLanguage = null;
|
||||
|
||||
const updateTranslation = () => {
|
||||
if (!window.i18n) return;
|
||||
|
||||
const newLanguage = window.i18n.getCurrentLocale();
|
||||
|
||||
// Only update if language changed or first time
|
||||
if (newLanguage !== currentLanguage) {
|
||||
currentLanguage = newLanguage;
|
||||
const translation = window.i18n.t(key, params);
|
||||
callback(translation);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial update
|
||||
updateTranslation();
|
||||
|
||||
// Listen for language changes
|
||||
window.addEventListener('languageChanged', updateTranslation);
|
||||
window.addEventListener('i18nReady', updateTranslation);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
window.removeEventListener('languageChanged', updateTranslation);
|
||||
window.removeEventListener('i18nReady', updateTranslation);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update multiple elements with translations
|
||||
* @param {Array} updates - Array of update configurations
|
||||
* Each update should have: { element, key, type: 'text'|'attribute', attribute?, params?, fallback? }
|
||||
*/
|
||||
export function batchUpdateTranslations(updates) {
|
||||
if (!window.i18n) return;
|
||||
|
||||
for (const update of updates) {
|
||||
const { element, key, type = 'text', attribute, params = {}, fallback } = update;
|
||||
|
||||
if (type === 'text') {
|
||||
updateElementText(element, key, params, fallback);
|
||||
} else if (type === 'attribute' && attribute) {
|
||||
updateElementAttribute(element, attribute, key, params, fallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,7 @@ async function initializeVirtualScroll(pageType) {
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error initializing virtual scroller for ${pageType}:`, error);
|
||||
showToast(`Failed to initialize ${pageType} page. Please reload.`, 'error');
|
||||
showToast('toast.general.pageInitFailed', { pageType }, 'error');
|
||||
|
||||
// Fallback: show a message in the grid
|
||||
grid.innerHTML = `
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user