mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-23 11:41:17 -03:00
Compare commits
21 Commits
v1.1.2
...
07f49559be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07f49559be | ||
|
|
b24b1a7e57 | ||
|
|
faf64f8986 | ||
|
|
a617487a43 | ||
|
|
3012a7aef3 | ||
|
|
499e19de34 | ||
|
|
9161762ca9 | ||
|
|
9bbd26efe6 | ||
|
|
258b2622d5 | ||
|
|
80ec9085dd | ||
|
|
c5c7373e10 | ||
|
|
b7721866e5 | ||
|
|
8314b9bedb | ||
|
|
75298a402f | ||
|
|
92b5efd414 | ||
|
|
33ee392b7b | ||
|
|
5237f8b7dc | ||
|
|
5107313fd1 | ||
|
|
95bbc66919 | ||
|
|
e268e59419 | ||
|
|
547e1f9498 |
@@ -1,153 +0,0 @@
|
||||
# Recipe Batch Import Feature Design
|
||||
|
||||
## Overview
|
||||
Enable users to import multiple images as recipes in a single operation, rather than processing them individually. This feature addresses the need for efficient bulk recipe creation from existing image collections.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ BatchImportManager.js │
|
||||
│ ├── InputCollector (收集URL列表/目录路径) │
|
||||
│ ├── ConcurrencyController (自适应并发控制) │
|
||||
│ ├── ProgressTracker (进度追踪) │
|
||||
│ └── ResultAggregator (结果汇总) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ batch_import_modal.html │
|
||||
│ └── 批量导入UI组件 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ batch_import_progress.css │
|
||||
│ └── 进度显示样式 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Backend │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ py/routes/handlers/recipe_handlers.py │
|
||||
│ ├── start_batch_import() - 启动批量导入 │
|
||||
│ ├── get_batch_import_progress() - 查询进度 │
|
||||
│ └── cancel_batch_import() - 取消导入 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ py/services/batch_import_service.py │
|
||||
│ ├── 自适应并发执行 │
|
||||
│ ├── 结果汇总 │
|
||||
│ └── WebSocket进度广播 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/lm/recipes/batch-import/start` | POST | 启动批量导入,返回 operation_id |
|
||||
| `/api/lm/recipes/batch-import/progress` | GET | 查询进度状态 |
|
||||
| `/api/lm/recipes/batch-import/cancel` | POST | 取消导入 |
|
||||
|
||||
## Backend Implementation Details
|
||||
|
||||
### BatchImportService
|
||||
|
||||
Location: `py/services/batch_import_service.py`
|
||||
|
||||
Key classes:
|
||||
- `BatchImportItem`: Dataclass for individual import item
|
||||
- `BatchImportProgress`: Dataclass for tracking progress
|
||||
- `BatchImportService`: Main service class
|
||||
|
||||
Features:
|
||||
- Adaptive concurrency control (adjusts based on success/failure rate)
|
||||
- WebSocket progress broadcasting
|
||||
- Graceful error handling (individual failures don't stop the batch)
|
||||
- Result aggregation
|
||||
|
||||
### WebSocket Message Format
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "batch_import_progress",
|
||||
"operation_id": "xxx",
|
||||
"total": 50,
|
||||
"completed": 23,
|
||||
"success": 21,
|
||||
"failed": 2,
|
||||
"skipped": 0,
|
||||
"current_item": "image_024.png",
|
||||
"status": "running"
|
||||
}
|
||||
```
|
||||
|
||||
### Input Types
|
||||
|
||||
1. **URL List**: Array of URLs (http/https)
|
||||
2. **Local Paths**: Array of local file paths
|
||||
3. **Directory**: Path to directory with optional recursive flag
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Invalid URLs/paths: Skip and record error
|
||||
- Download failures: Record error, continue
|
||||
- Metadata extraction failures: Mark as "no metadata"
|
||||
- Duplicate detection: Option to skip duplicates
|
||||
|
||||
## Frontend Implementation Details (TODO)
|
||||
|
||||
### UI Components
|
||||
|
||||
1. **BatchImportModal**: Main modal with tabs for URLs/Directory input
|
||||
2. **ProgressDisplay**: Real-time progress bar and status
|
||||
3. **ResultsSummary**: Final results with success/failure breakdown
|
||||
|
||||
### Adaptive Concurrency Controller
|
||||
|
||||
```javascript
|
||||
class AdaptiveConcurrencyController {
|
||||
constructor(options = {}) {
|
||||
this.minConcurrency = options.minConcurrency || 1;
|
||||
this.maxConcurrency = options.maxConcurrency || 5;
|
||||
this.currentConcurrency = options.initialConcurrency || 3;
|
||||
}
|
||||
|
||||
adjustConcurrency(taskDuration, success) {
|
||||
if (success && taskDuration < 1000 && this.currentConcurrency < this.maxConcurrency) {
|
||||
this.currentConcurrency = Math.min(this.currentConcurrency + 1, this.maxConcurrency);
|
||||
}
|
||||
if (!success || taskDuration > 10000) {
|
||||
this.currentConcurrency = Math.max(this.currentConcurrency - 1, this.minConcurrency);
|
||||
}
|
||||
return this.currentConcurrency;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
Backend (implemented):
|
||||
├── py/services/batch_import_service.py # 后端服务
|
||||
├── py/routes/handlers/batch_import_handler.py # API处理器 (added to recipe_handlers.py)
|
||||
├── tests/services/test_batch_import_service.py # 单元测试
|
||||
└── tests/routes/test_batch_import_routes.py # API集成测试
|
||||
|
||||
Frontend (TODO):
|
||||
├── static/js/managers/BatchImportManager.js # 主管理器
|
||||
├── static/js/managers/batch/ # 子模块
|
||||
│ ├── ConcurrencyController.js # 并发控制
|
||||
│ ├── ProgressTracker.js # 进度追踪
|
||||
│ └── ResultAggregator.js # 结果汇总
|
||||
├── static/css/components/batch-import-modal.css # 样式
|
||||
└── templates/components/batch_import_modal.html # Modal模板
|
||||
```
|
||||
|
||||
## Implementation Status
|
||||
|
||||
- [x] Backend BatchImportService
|
||||
- [x] Backend API handlers
|
||||
- [x] WebSocket progress broadcasting
|
||||
- [x] Unit tests
|
||||
- [x] Integration tests
|
||||
- [ ] Frontend BatchImportManager
|
||||
- [ ] Frontend UI components
|
||||
- [ ] E2E tests
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -28,3 +28,6 @@ vue-widgets/dist/
|
||||
|
||||
# Hypothesis test cache
|
||||
.hypothesis/
|
||||
|
||||
# Working/research notes (not committed)
|
||||
.docs/
|
||||
|
||||
@@ -251,7 +251,18 @@
|
||||
"toggle": "Theme wechseln",
|
||||
"switchToLight": "Zu hellem Theme wechseln",
|
||||
"switchToDark": "Zu dunklem Theme wechseln",
|
||||
"switchToAuto": "Zu automatischem Theme wechseln"
|
||||
"switchToAuto": "Zu automatischem Theme wechseln",
|
||||
"presets": "Theme-Voreinstellungen",
|
||||
"default": "Standard",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "Modus",
|
||||
"light": "Hell",
|
||||
"dark": "Dunkel",
|
||||
"auto": "Auto"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Updates prüfen",
|
||||
@@ -263,6 +274,9 @@
|
||||
"civitaiApiKey": "Civitai API Key",
|
||||
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
|
||||
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
|
||||
"civitaiApiKeyConfigured": "Konfiguriert",
|
||||
"civitaiApiKeyNotConfigured": "Nicht konfiguriert",
|
||||
"civitaiApiKeySet": "Einrichten",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai-Host",
|
||||
"help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.",
|
||||
@@ -303,6 +317,7 @@
|
||||
"downloads": "Downloads",
|
||||
"videoSettings": "Video-Einstellungen",
|
||||
"layoutSettings": "Layout-Einstellungen",
|
||||
"licenseIcons": "Lizenzsymbole",
|
||||
"misc": "Verschiedenes",
|
||||
"backup": "Backups",
|
||||
"folderSettings": "Standard-Roots",
|
||||
@@ -583,6 +598,10 @@
|
||||
"label": "Früher Zugriff Updates ausblenden",
|
||||
"help": "Nur Early-Access-Updates"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Aktualisierte Lizenzsymbole verwenden",
|
||||
"useNewStyleHelp": "Lizenzberechtigungen mit farbigen Indikatoren (neuer Stil) oder nur Einschränkungssymbolen (klassischer Stil) anzeigen. Orientiert sich am aktuellen CivitAI-Design."
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
|
||||
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen",
|
||||
@@ -1424,15 +1443,6 @@
|
||||
"duplicate": "Dieser Tag existiert bereits"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "Tastatur-Navigation:",
|
||||
"shortcuts": {
|
||||
"pageUp": "Eine Seite nach oben scrollen",
|
||||
"pageDown": "Eine Seite nach unten scrollen",
|
||||
"home": "Zum Anfang springen",
|
||||
"end": "Zum Ende springen"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "Initialisierung",
|
||||
"message": "Ihr Arbeitsbereich wird vorbereitet...",
|
||||
|
||||
@@ -251,7 +251,18 @@
|
||||
"toggle": "Toggle theme",
|
||||
"switchToLight": "Switch to light theme",
|
||||
"switchToDark": "Switch to dark theme",
|
||||
"switchToAuto": "Switch to auto theme"
|
||||
"switchToAuto": "Switch to auto theme",
|
||||
"presets": "Theme Presets",
|
||||
"default": "Default",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "Mode",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"auto": "Auto"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Check Updates",
|
||||
@@ -263,6 +274,9 @@
|
||||
"civitaiApiKey": "Civitai API Key",
|
||||
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
|
||||
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
||||
"civitaiApiKeyConfigured": "Configured",
|
||||
"civitaiApiKeyNotConfigured": "Not configured",
|
||||
"civitaiApiKeySet": "Set up",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai host",
|
||||
"help": "Choose which Civitai site opens when using View on Civitai links.",
|
||||
@@ -303,6 +317,7 @@
|
||||
"downloads": "Downloads",
|
||||
"videoSettings": "Video Settings",
|
||||
"layoutSettings": "Layout Settings",
|
||||
"licenseIcons": "License Icons",
|
||||
"misc": "Miscellaneous",
|
||||
"backup": "Backups",
|
||||
"folderSettings": "Default Roots",
|
||||
@@ -583,6 +598,10 @@
|
||||
"label": "Hide Early Access Updates",
|
||||
"help": "When enabled, models with only early access updates will not show 'Update available' badge"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Use updated license icons",
|
||||
"useNewStyleHelp": "Display license permissions with colored indicators (new style) or restriction-only icons (classic style). Mirroring the current CivitAI design."
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Include Trigger Words in LoRA Syntax",
|
||||
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard",
|
||||
@@ -1424,15 +1443,6 @@
|
||||
"duplicate": "This tag already exists"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "Keyboard Navigation:",
|
||||
"shortcuts": {
|
||||
"pageUp": "Scroll up one page",
|
||||
"pageDown": "Scroll down one page",
|
||||
"home": "Jump to top",
|
||||
"end": "Jump to bottom"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "Initializing",
|
||||
"message": "Preparing your workspace...",
|
||||
|
||||
@@ -251,7 +251,18 @@
|
||||
"toggle": "Cambiar tema",
|
||||
"switchToLight": "Cambiar a tema claro",
|
||||
"switchToDark": "Cambiar a tema oscuro",
|
||||
"switchToAuto": "Cambiar a tema automático"
|
||||
"switchToAuto": "Cambiar a tema automático",
|
||||
"presets": "Preajustes de tema",
|
||||
"default": "Predeterminado",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "Modo",
|
||||
"light": "Claro",
|
||||
"dark": "Oscuro",
|
||||
"auto": "Auto"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Comprobar actualizaciones",
|
||||
@@ -263,6 +274,9 @@
|
||||
"civitaiApiKey": "Clave API de Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
|
||||
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
|
||||
"civitaiApiKeyConfigured": "Configurado",
|
||||
"civitaiApiKeyNotConfigured": "No configurado",
|
||||
"civitaiApiKeySet": "Configurar",
|
||||
"civitaiHost": {
|
||||
"label": "Host de Civitai",
|
||||
"help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".",
|
||||
@@ -303,6 +317,7 @@
|
||||
"downloads": "Descargas",
|
||||
"videoSettings": "Configuración de video",
|
||||
"layoutSettings": "Configuración de diseño",
|
||||
"licenseIcons": "Iconos de licencia",
|
||||
"misc": "Varios",
|
||||
"backup": "Copias de seguridad",
|
||||
"folderSettings": "Raíces predeterminadas",
|
||||
@@ -583,6 +598,10 @@
|
||||
"label": "Ocultar actualizaciones de acceso temprano",
|
||||
"help": "Solo actualizaciones de acceso temprano"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Usar iconos de licencia actualizados",
|
||||
"useNewStyleHelp": "Mostrar permisos de licencia con indicadores de color (nuevo estilo) o solo iconos de restricción (estilo clásico). Refleja el diseño actual de CivitAI."
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
|
||||
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles",
|
||||
@@ -1424,15 +1443,6 @@
|
||||
"duplicate": "Esta etiqueta ya existe"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "Navegación por teclado:",
|
||||
"shortcuts": {
|
||||
"pageUp": "Desplazar hacia arriba una página",
|
||||
"pageDown": "Desplazar hacia abajo una página",
|
||||
"home": "Saltar al inicio",
|
||||
"end": "Saltar al final"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "Inicializando",
|
||||
"message": "Preparando tu espacio de trabajo...",
|
||||
|
||||
@@ -251,7 +251,18 @@
|
||||
"toggle": "Basculer le thème",
|
||||
"switchToLight": "Passer au thème clair",
|
||||
"switchToDark": "Passer au thème sombre",
|
||||
"switchToAuto": "Passer au thème automatique"
|
||||
"switchToAuto": "Passer au thème automatique",
|
||||
"presets": "Préréglages de thème",
|
||||
"default": "Par défaut",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "Mode",
|
||||
"light": "Clair",
|
||||
"dark": "Sombre",
|
||||
"auto": "Auto"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Vérifier les mises à jour",
|
||||
@@ -263,6 +274,9 @@
|
||||
"civitaiApiKey": "Clé API Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
|
||||
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
|
||||
"civitaiApiKeyConfigured": "Configuré",
|
||||
"civitaiApiKeyNotConfigured": "Non configuré",
|
||||
"civitaiApiKeySet": "Configurer",
|
||||
"civitaiHost": {
|
||||
"label": "Hôte Civitai",
|
||||
"help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».",
|
||||
@@ -303,6 +317,7 @@
|
||||
"downloads": "Téléchargements",
|
||||
"videoSettings": "Paramètres vidéo",
|
||||
"layoutSettings": "Paramètres d'affichage",
|
||||
"licenseIcons": "Icônes de licence",
|
||||
"misc": "Divers",
|
||||
"backup": "Sauvegardes",
|
||||
"folderSettings": "Racines par défaut",
|
||||
@@ -583,6 +598,10 @@
|
||||
"label": "Masquer les mises à jour en accès anticipé",
|
||||
"help": "Seulement les mises à jour en accès anticipé"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Utiliser les icônes de licence mises à jour",
|
||||
"useNewStyleHelp": "Afficher les permissions de licence avec des indicateurs colorés (nouveau style) ou des icônes de restriction uniquement (style classique). Reprend le design actuel de CivitAI."
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA",
|
||||
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers",
|
||||
@@ -1424,15 +1443,6 @@
|
||||
"duplicate": "Ce tag existe déjà"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "Navigation au clavier :",
|
||||
"shortcuts": {
|
||||
"pageUp": "Défiler d'une page vers le haut",
|
||||
"pageDown": "Défiler d'une page vers le bas",
|
||||
"home": "Aller en haut",
|
||||
"end": "Aller en bas"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "Initialisation",
|
||||
"message": "Préparation de votre espace de travail...",
|
||||
|
||||
@@ -251,7 +251,18 @@
|
||||
"toggle": "החלף ערכת נושא",
|
||||
"switchToLight": "עבור לערכת נושא בהירה",
|
||||
"switchToDark": "עבור לערכת נושא כהה",
|
||||
"switchToAuto": "עבור לערכת נושא אוטומטית"
|
||||
"switchToAuto": "עבור לערכת נושא אוטומטית",
|
||||
"presets": "ערכות נושא מוגדרות",
|
||||
"default": "ברירת מחדל",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "מצב",
|
||||
"light": "בהיר",
|
||||
"dark": "כהה",
|
||||
"auto": "אוטומטי"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "בדוק עדכונים",
|
||||
@@ -263,6 +274,9 @@
|
||||
"civitaiApiKey": "מפתח API של Civitai",
|
||||
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
|
||||
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
|
||||
"civitaiApiKeyConfigured": "מוגדר",
|
||||
"civitaiApiKeyNotConfigured": "לא מוגדר",
|
||||
"civitaiApiKeySet": "הגדר",
|
||||
"civitaiHost": {
|
||||
"label": "מארח Civitai",
|
||||
"help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".",
|
||||
@@ -303,6 +317,7 @@
|
||||
"downloads": "הורדות",
|
||||
"videoSettings": "הגדרות וידאו",
|
||||
"layoutSettings": "הגדרות פריסה",
|
||||
"licenseIcons": "סמלי רישיון",
|
||||
"misc": "שונות",
|
||||
"backup": "גיבויים",
|
||||
"folderSettings": "תיקיות ברירת מחדל",
|
||||
@@ -583,6 +598,10 @@
|
||||
"label": "הסתר עדכוני גישה מוקדמת",
|
||||
"help": "רק עדכוני גישה מוקדמת"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "השתמש בסמלי רישיון מעודכנים",
|
||||
"useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI."
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA",
|
||||
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח",
|
||||
@@ -1424,15 +1443,6 @@
|
||||
"duplicate": "תגית זו כבר קיימת"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "ניווט במקלדת:",
|
||||
"shortcuts": {
|
||||
"pageUp": "גלול עמוד אחד למעלה",
|
||||
"pageDown": "גלול עמוד אחד למטה",
|
||||
"home": "קפוץ להתחלה",
|
||||
"end": "קפוץ לסוף"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "מאתחל",
|
||||
"message": "מכין את סביבת העבודה שלך...",
|
||||
|
||||
@@ -251,7 +251,18 @@
|
||||
"toggle": "テーマの切り替え",
|
||||
"switchToLight": "ライトテーマに切り替え",
|
||||
"switchToDark": "ダークテーマに切り替え",
|
||||
"switchToAuto": "自動テーマに切り替え"
|
||||
"switchToAuto": "自動テーマに切り替え",
|
||||
"presets": "テーマプリセット",
|
||||
"default": "デフォルト",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "モード",
|
||||
"light": "ライト",
|
||||
"dark": "ダーク",
|
||||
"auto": "自動"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "更新確認",
|
||||
@@ -263,6 +274,9 @@
|
||||
"civitaiApiKey": "Civitai APIキー",
|
||||
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
|
||||
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
|
||||
"civitaiApiKeyConfigured": "設定済み",
|
||||
"civitaiApiKeyNotConfigured": "未設定",
|
||||
"civitaiApiKeySet": "設定",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai ホスト",
|
||||
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
|
||||
@@ -303,6 +317,7 @@
|
||||
"downloads": "ダウンロード",
|
||||
"videoSettings": "動画設定",
|
||||
"layoutSettings": "レイアウト設定",
|
||||
"licenseIcons": "ライセンスアイコン",
|
||||
"misc": "その他",
|
||||
"backup": "バックアップ",
|
||||
"folderSettings": "デフォルトルート",
|
||||
@@ -583,6 +598,10 @@
|
||||
"label": "早期アクセス更新を非表示",
|
||||
"help": "早期アクセスのみの更新"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "更新されたライセンスアイコンを使用",
|
||||
"useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "LoRA構文にトリガーワードを含める",
|
||||
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます",
|
||||
@@ -1424,15 +1443,6 @@
|
||||
"duplicate": "このタグは既に存在します"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "キーボードナビゲーション:",
|
||||
"shortcuts": {
|
||||
"pageUp": "1ページ上にスクロール",
|
||||
"pageDown": "1ページ下にスクロール",
|
||||
"home": "トップにジャンプ",
|
||||
"end": "ボトムにジャンプ"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "初期化中",
|
||||
"message": "ワークスペースを準備中...",
|
||||
|
||||
@@ -251,7 +251,18 @@
|
||||
"toggle": "테마 토글",
|
||||
"switchToLight": "라이트 테마로 전환",
|
||||
"switchToDark": "다크 테마로 전환",
|
||||
"switchToAuto": "자동 테마로 전환"
|
||||
"switchToAuto": "자동 테마로 전환",
|
||||
"presets": "테마 프리셋",
|
||||
"default": "기본",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "모드",
|
||||
"light": "라이트",
|
||||
"dark": "다크",
|
||||
"auto": "자동"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "업데이트 확인",
|
||||
@@ -263,6 +274,9 @@
|
||||
"civitaiApiKey": "Civitai API 키",
|
||||
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
|
||||
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
|
||||
"civitaiApiKeyConfigured": "설정됨",
|
||||
"civitaiApiKeyNotConfigured": "설정되지 않음",
|
||||
"civitaiApiKeySet": "설정",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 호스트",
|
||||
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
|
||||
@@ -303,6 +317,7 @@
|
||||
"downloads": "다운로드",
|
||||
"videoSettings": "비디오 설정",
|
||||
"layoutSettings": "레이아웃 설정",
|
||||
"licenseIcons": "라이선스 아이콘",
|
||||
"misc": "기타",
|
||||
"backup": "백업",
|
||||
"folderSettings": "기본 루트",
|
||||
@@ -583,6 +598,10 @@
|
||||
"label": "얼리 액세스 업데이트 숨기기",
|
||||
"help": "얼리 액세스 업데이트만"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
|
||||
"useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다."
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
|
||||
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다",
|
||||
@@ -1424,15 +1443,6 @@
|
||||
"duplicate": "이 태그는 이미 존재합니다"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "키보드 내비게이션:",
|
||||
"shortcuts": {
|
||||
"pageUp": "한 페이지 위로 스크롤",
|
||||
"pageDown": "한 페이지 아래로 스크롤",
|
||||
"home": "맨 위로 이동",
|
||||
"end": "맨 아래로 이동"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "초기화 중",
|
||||
"message": "작업공간을 준비하고 있습니다...",
|
||||
|
||||
@@ -251,7 +251,18 @@
|
||||
"toggle": "Переключить тему",
|
||||
"switchToLight": "Переключить на светлую тему",
|
||||
"switchToDark": "Переключить на тёмную тему",
|
||||
"switchToAuto": "Переключить на автоматическую тему"
|
||||
"switchToAuto": "Переключить на автоматическую тему",
|
||||
"presets": "Предустановки тем",
|
||||
"default": "По умолчанию",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "Режим",
|
||||
"light": "Светлый",
|
||||
"dark": "Тёмный",
|
||||
"auto": "Авто"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Проверить обновления",
|
||||
@@ -263,6 +274,9 @@
|
||||
"civitaiApiKey": "Ключ API Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
|
||||
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
|
||||
"civitaiApiKeyConfigured": "Настроен",
|
||||
"civitaiApiKeyNotConfigured": "Не настроен",
|
||||
"civitaiApiKeySet": "Настроить",
|
||||
"civitaiHost": {
|
||||
"label": "Хост Civitai",
|
||||
"help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».",
|
||||
@@ -303,6 +317,7 @@
|
||||
"downloads": "Загрузки",
|
||||
"videoSettings": "Настройки видео",
|
||||
"layoutSettings": "Настройки макета",
|
||||
"licenseIcons": "Значки лицензии",
|
||||
"misc": "Разное",
|
||||
"backup": "Резервные копии",
|
||||
"folderSettings": "Корневые папки",
|
||||
@@ -583,6 +598,10 @@
|
||||
"label": "Скрыть обновления раннего доступа",
|
||||
"help": "Только обновления раннего доступа"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Использовать обновлённые значки лицензии",
|
||||
"useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI."
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
|
||||
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена",
|
||||
@@ -1424,15 +1443,6 @@
|
||||
"duplicate": "Этот тег уже существует"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "Навигация с клавиатуры:",
|
||||
"shortcuts": {
|
||||
"pageUp": "Прокрутить на страницу вверх",
|
||||
"pageDown": "Прокрутить на страницу вниз",
|
||||
"home": "Перейти к началу",
|
||||
"end": "Перейти к концу"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "Инициализация",
|
||||
"message": "Подготовка вашего рабочего пространства...",
|
||||
|
||||
@@ -251,7 +251,18 @@
|
||||
"toggle": "切换主题",
|
||||
"switchToLight": "切换到浅色主题",
|
||||
"switchToDark": "切换到深色主题",
|
||||
"switchToAuto": "切换到自动主题"
|
||||
"switchToAuto": "切换到自动主题",
|
||||
"presets": "主题预设",
|
||||
"default": "默认",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "模式",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"auto": "自动"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "检查更新",
|
||||
@@ -263,6 +274,9 @@
|
||||
"civitaiApiKey": "Civitai API 密钥",
|
||||
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
|
||||
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
|
||||
"civitaiApiKeyConfigured": "已配置",
|
||||
"civitaiApiKeyNotConfigured": "未配置",
|
||||
"civitaiApiKeySet": "设置",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 站点",
|
||||
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
|
||||
@@ -303,6 +317,7 @@
|
||||
"downloads": "下载",
|
||||
"videoSettings": "视频设置",
|
||||
"layoutSettings": "布局设置",
|
||||
"licenseIcons": "许可协议图标",
|
||||
"misc": "其他",
|
||||
"backup": "备份",
|
||||
"folderSettings": "默认根目录",
|
||||
@@ -583,6 +598,10 @@
|
||||
"label": "隐藏抢先体验更新",
|
||||
"help": "抢先体验更新"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "使用新版许可协议图标",
|
||||
"useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "复制 LoRA 语法时包含触发词",
|
||||
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词",
|
||||
@@ -1424,15 +1443,6 @@
|
||||
"duplicate": "该标签已存在"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "键盘导航:",
|
||||
"shortcuts": {
|
||||
"pageUp": "向上一页滚动",
|
||||
"pageDown": "向下一页滚动",
|
||||
"home": "跳到顶部",
|
||||
"end": "跳到底部"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "初始化",
|
||||
"message": "正在准备你的工作空间...",
|
||||
|
||||
@@ -251,7 +251,18 @@
|
||||
"toggle": "切換主題",
|
||||
"switchToLight": "切換至淺色主題",
|
||||
"switchToDark": "切換至深色主題",
|
||||
"switchToAuto": "自動主題"
|
||||
"switchToAuto": "自動主題",
|
||||
"presets": "主題預設",
|
||||
"default": "預設",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "模式",
|
||||
"light": "淺色",
|
||||
"dark": "深色",
|
||||
"auto": "自動"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "檢查更新",
|
||||
@@ -263,6 +274,9 @@
|
||||
"civitaiApiKey": "Civitai API 金鑰",
|
||||
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
|
||||
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
|
||||
"civitaiApiKeyConfigured": "已設定",
|
||||
"civitaiApiKeyNotConfigured": "未設定",
|
||||
"civitaiApiKeySet": "設定",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 站點",
|
||||
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
|
||||
@@ -303,6 +317,7 @@
|
||||
"downloads": "下載",
|
||||
"videoSettings": "影片設定",
|
||||
"layoutSettings": "版面設定",
|
||||
"licenseIcons": "許可協議圖標",
|
||||
"misc": "其他",
|
||||
"backup": "備份",
|
||||
"folderSettings": "預設根目錄",
|
||||
@@ -583,6 +598,10 @@
|
||||
"label": "隱藏搶先體驗更新",
|
||||
"help": "搶先體驗更新"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "使用新版許可協議圖標",
|
||||
"useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。"
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "在 LoRA 語法中包含觸發詞",
|
||||
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞",
|
||||
@@ -1424,15 +1443,6 @@
|
||||
"duplicate": "此標籤已存在"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "鍵盤導覽:",
|
||||
"shortcuts": {
|
||||
"pageUp": "向上捲動一頁",
|
||||
"pageDown": "向下捲動一頁",
|
||||
"home": "跳至頂部",
|
||||
"end": "跳至底部"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "初始化",
|
||||
"message": "正在準備您的工作區...",
|
||||
|
||||
@@ -1328,6 +1328,9 @@ class SettingsHandler:
|
||||
"folder_paths",
|
||||
"libraries",
|
||||
"active_library",
|
||||
# Sensitive — never expose the actual value to the frontend;
|
||||
# frontend receives a boolean instead (civitai_api_key_set).
|
||||
"civitai_api_key",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1382,6 +1385,9 @@ class SettingsHandler:
|
||||
value = self._settings.get(key)
|
||||
if value is not None:
|
||||
response_data[key] = value
|
||||
# Sensitive fields: only expose a boolean indicating whether set
|
||||
raw_key = self._settings.get("civitai_api_key")
|
||||
response_data["civitai_api_key_set"] = bool(raw_key)
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
if settings_file:
|
||||
response_data["settings_file"] = settings_file
|
||||
|
||||
@@ -1272,6 +1272,14 @@ class ModelQueryHandler:
|
||||
license_flags = (model_data or {}).get("license_flags")
|
||||
if license_flags is not None:
|
||||
response_payload["license_flags"] = int(license_flags)
|
||||
# Include the user's license icon style preference so the
|
||||
# ComfyUI tooltip can pick the right set without a separate
|
||||
# API call.
|
||||
try:
|
||||
settings = get_settings_manager()
|
||||
response_payload["use_new_license_icons"] = settings.get("use_new_license_icons", True)
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response(response_payload)
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -1820,6 +1828,39 @@ class ModelDownloadHandler:
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def update_download_queue_status(self, request: web.Request) -> web.Response:
|
||||
"""Update the status of a queue item (non-terminal transitions).
|
||||
|
||||
Supported transitions include ``queued → downloading``,
|
||||
``downloading → paused``, ``paused → downloading``, etc.
|
||||
Terminal transitions (``completed``, ``failed``, ``canceled``)
|
||||
should use ``complete_download_in_queue`` instead.
|
||||
"""
|
||||
try:
|
||||
download_id = request.query.get("download_id")
|
||||
status = request.query.get("status")
|
||||
if not download_id or not status:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "download_id and status are required",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
service = await DownloadQueueService.get_instance()
|
||||
updated = await service.update_status(download_id, status)
|
||||
if not updated:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Download not found in queue"},
|
||||
status=404,
|
||||
)
|
||||
return web.json_response({"success": True})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error updating download queue status: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class ModelCivitaiHandler:
|
||||
"""CivitAI integration endpoints."""
|
||||
@@ -2864,6 +2905,7 @@ class ModelHandlerSet:
|
||||
"retry_all_failed_downloads": self.download.retry_all_failed_downloads,
|
||||
"complete_download_in_queue": self.download.complete_download_in_queue,
|
||||
"get_download_stats": self.download.get_download_stats,
|
||||
"update_download_queue_status": self.download.update_download_queue_status,
|
||||
"get_civitai_versions": self.civitai.get_civitai_versions,
|
||||
"get_civitai_model_by_version": self.civitai.get_civitai_model_by_version,
|
||||
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,
|
||||
|
||||
@@ -138,6 +138,9 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/complete", "complete_download_in_queue"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/status", "update_download_queue_status"
|
||||
),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
|
||||
RouteDefinition("GET", "/{prefix}", "handle_models_page"),
|
||||
)
|
||||
|
||||
@@ -11,6 +11,8 @@ from ..config import config
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..services.server_i18n import server_i18n
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..services.model_query import normalize_sub_type, resolve_sub_type
|
||||
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES
|
||||
from ..utils.usage_stats import UsageStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -140,6 +142,21 @@ class StatsRoutes:
|
||||
# Get usage statistics
|
||||
usage_data = await self.usage_stats.get_stats()
|
||||
|
||||
# CivitAI model type distribution across all model types
|
||||
# Use the same logic as the filter panel: normalize_sub_type(resolve_sub_type(entry))
|
||||
# with sub-type validation per model type
|
||||
model_types_counter: Counter[str] = Counter()
|
||||
for entry in lora_cache.raw_data:
|
||||
ntype = normalize_sub_type(resolve_sub_type(entry))
|
||||
if ntype and ntype in VALID_LORA_SUB_TYPES:
|
||||
model_types_counter[ntype] += 1
|
||||
for entry in checkpoint_cache.raw_data:
|
||||
ntype = normalize_sub_type(resolve_sub_type(entry))
|
||||
if ntype and ntype in VALID_CHECKPOINT_SUB_TYPES:
|
||||
model_types_counter[ntype] += 1
|
||||
# Embeddings: always count as "embedding" regardless of CivitAI sub-type
|
||||
model_types_counter['embedding'] = len(embedding_cache.raw_data)
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'data': {
|
||||
@@ -154,7 +171,8 @@ class StatsRoutes:
|
||||
'total_generations': usage_data.get('total_executions', 0),
|
||||
'unused_loras': self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {})),
|
||||
'unused_checkpoints': self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {})),
|
||||
'unused_embeddings': self._count_unused_models(embedding_cache.raw_data, usage_data.get('embeddings', {}))
|
||||
'unused_embeddings': self._count_unused_models(embedding_cache.raw_data, usage_data.get('embeddings', {})),
|
||||
'model_types_distribution': dict(model_types_counter.most_common())
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from .metadata_service import get_default_metadata_provider, get_metadata_provid
|
||||
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
|
||||
from .aria2_downloader import Aria2Error, get_aria2_downloader
|
||||
from .aria2_transfer_state import Aria2TransferStateStore
|
||||
from .download_queue_service import DownloadQueueService
|
||||
|
||||
# Download to temporary file first
|
||||
import tempfile
|
||||
@@ -360,6 +361,15 @@ class DownloadManager:
|
||||
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
|
||||
await self._persist_aria2_state(task_id)
|
||||
|
||||
# Update SQLite queue status to 'downloading'
|
||||
try:
|
||||
queue_service = await DownloadQueueService.get_instance()
|
||||
await queue_service.update_status(task_id, "downloading")
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to update queue status for %s", task_id, exc_info=True
|
||||
)
|
||||
|
||||
# Use original download implementation
|
||||
try:
|
||||
# Check for cancellation before starting
|
||||
@@ -396,6 +406,22 @@ class DownloadManager:
|
||||
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
|
||||
await self._persist_aria2_state(task_id)
|
||||
|
||||
# Move queue item to history on completion
|
||||
try:
|
||||
queue_service = await DownloadQueueService.get_instance()
|
||||
await queue_service.complete_download(
|
||||
download_id=task_id,
|
||||
status=result.get("status", "completed") if result.get("success") else "failed",
|
||||
error=result.get("error") if not result.get("success") else None,
|
||||
file_path=result.get("file_path"),
|
||||
bytes_downloaded=self._active_downloads.get(task_id, {}).get("bytes_downloaded", 0),
|
||||
total_bytes=self._active_downloads.get(task_id, {}).get("total_bytes"),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to complete queue item for %s", task_id, exc_info=True
|
||||
)
|
||||
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
# Handle cancellation
|
||||
@@ -404,6 +430,19 @@ class DownloadManager:
|
||||
self._active_downloads[task_id]["bytes_per_second"] = 0.0
|
||||
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
|
||||
await self._persist_aria2_state(task_id)
|
||||
|
||||
# Move queue item to history as canceled
|
||||
try:
|
||||
queue_service = await DownloadQueueService.get_instance()
|
||||
await queue_service.complete_download(
|
||||
download_id=task_id,
|
||||
status="canceled",
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to cancel queue item for %s", task_id, exc_info=True
|
||||
)
|
||||
|
||||
logger.info(f"Download cancelled for task {task_id}")
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -417,6 +456,22 @@ class DownloadManager:
|
||||
self._active_downloads[task_id]["bytes_per_second"] = 0.0
|
||||
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
|
||||
await self._persist_aria2_state(task_id)
|
||||
|
||||
# Move queue item to history as failed
|
||||
try:
|
||||
queue_service = await DownloadQueueService.get_instance()
|
||||
await queue_service.complete_download(
|
||||
download_id=task_id,
|
||||
status="failed",
|
||||
error=str(e),
|
||||
bytes_downloaded=self._active_downloads.get(task_id, {}).get("bytes_downloaded", 0),
|
||||
total_bytes=self._active_downloads.get(task_id, {}).get("total_bytes"),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to complete queue item for %s", task_id, exc_info=True
|
||||
)
|
||||
|
||||
return {"success": False, "error": str(e)}
|
||||
finally:
|
||||
# Schedule cleanup of download record after delay
|
||||
|
||||
@@ -105,6 +105,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"download_skip_base_models": [],
|
||||
"backup_auto_enabled": True,
|
||||
"backup_retention_count": 5,
|
||||
"use_new_license_icons": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +134,9 @@ class SettingsManager:
|
||||
self._template_path = (
|
||||
Path(__file__).resolve().parents[2] / "settings.json.example"
|
||||
)
|
||||
# Known placeholder value in settings.json.example; any file containing
|
||||
# this value should be treated as "not configured".
|
||||
self._TEMPLATE_PLACEHOLDER_API_KEY = "your_civitai_api_key_here"
|
||||
self.settings = self._load_settings()
|
||||
self._migrate_setting_keys()
|
||||
self._ensure_default_settings()
|
||||
@@ -164,6 +168,12 @@ class SettingsManager:
|
||||
self._original_disk_payload = copy.deepcopy(data)
|
||||
if self._matches_template_payload(data):
|
||||
self._preserve_disk_template = True
|
||||
# Clean up the template placeholder so it is not treated
|
||||
# as a real key (affects both the frontend boolean and
|
||||
# the downloader's Authorization header).
|
||||
placeholder = self._TEMPLATE_PLACEHOLDER_API_KEY
|
||||
if data.get("civitai_api_key") == placeholder:
|
||||
data["civitai_api_key"] = ""
|
||||
return data
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.error("Failed to parse settings.json: %s", exc)
|
||||
|
||||
@@ -39,6 +39,9 @@ async def calculate_sha256(file_path: str) -> str:
|
||||
Uses ``posix_fadvise`` with ``POSIX_FADV_DONTNEED`` to avoid polluting the OS page
|
||||
cache — critical on WSL where cached file pages live inside the VM and are not
|
||||
accounted for in guest ``used`` memory, causing VmmemWSL to balloon.
|
||||
|
||||
On Windows/macOS where ``posix_fadvise`` is not available the hint is silently
|
||||
skipped.
|
||||
"""
|
||||
sha256_hash = hashlib.sha256()
|
||||
chunk_size = _get_hash_chunk_size_bytes()
|
||||
@@ -48,7 +51,9 @@ async def calculate_sha256(file_path: str) -> str:
|
||||
sha256_hash.update(byte_block)
|
||||
# Evict pages after reading so the data doesn't linger in the kernel page
|
||||
# cache — on WSL this otherwise appears as unreclaimable VmmemWSL growth.
|
||||
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
|
||||
# Guard against platforms (Windows, macOS) that lack posix_fadvise.
|
||||
if hasattr(os, "posix_fadvise") and hasattr(os, "POSIX_FADV_DONTNEED"):
|
||||
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -349,8 +349,8 @@
|
||||
}
|
||||
|
||||
.progress-percentage {
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
@@ -365,9 +365,9 @@
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--lora-accent), oklch(from var(--lora-accent) calc(l + 0.1) c h));
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
background: var(--lora-accent);
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: width var(--transition-base);
|
||||
}
|
||||
|
||||
/* Progress Stats */
|
||||
@@ -389,27 +389,26 @@
|
||||
}
|
||||
|
||||
.stat-item.success {
|
||||
border-left: 3px solid #00B87A;
|
||||
border-left: 4px solid var(--color-success);
|
||||
}
|
||||
|
||||
.stat-item.failed {
|
||||
border-left: 3px solid var(--lora-error);
|
||||
border-left: 4px solid var(--color-error);
|
||||
}
|
||||
|
||||
.stat-item.skipped {
|
||||
border-left: 3px solid var(--lora-warning);
|
||||
border-left: 4px solid var(--color-warning);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.4em;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@@ -425,8 +424,7 @@
|
||||
}
|
||||
|
||||
.current-item-label {
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -449,27 +447,29 @@
|
||||
}
|
||||
|
||||
.results-header {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.results-icon {
|
||||
font-size: 3em;
|
||||
color: #00B87A;
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: var(--text-xl);
|
||||
color: var(--color-success);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.results-icon.warning {
|
||||
color: var(--lora-warning);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.results-icon.error {
|
||||
color: var(--lora-error);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.results-title {
|
||||
font-size: 1.3em;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@@ -493,27 +493,26 @@
|
||||
}
|
||||
|
||||
.result-card.success {
|
||||
border-left: 3px solid #00B87A;
|
||||
border-left: 4px solid var(--color-success);
|
||||
}
|
||||
|
||||
.result-card.failed {
|
||||
border-left: 3px solid var(--lora-error);
|
||||
border-left: 4px solid var(--color-error);
|
||||
}
|
||||
|
||||
.result-card.skipped {
|
||||
border-left: 3px solid var(--lora-warning);
|
||||
border-left: 4px solid var(--color-warning);
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.result-value {
|
||||
font-size: 1.4em;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@@ -527,13 +526,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
cursor: pointer;
|
||||
color: var(--lora-accent);
|
||||
font-weight: 500;
|
||||
font-weight: var(--weight-medium);
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: background 0.2s;
|
||||
transition: background var(--transition-base);
|
||||
}
|
||||
|
||||
.details-toggle:hover {
|
||||
@@ -541,7 +540,7 @@
|
||||
}
|
||||
|
||||
.details-toggle i {
|
||||
transition: transform 0.2s;
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.details-toggle.expanded i {
|
||||
@@ -561,10 +560,10 @@
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.9em;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.result-item:last-child {
|
||||
@@ -572,28 +571,23 @@
|
||||
}
|
||||
|
||||
.result-item-status {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8em;
|
||||
font-size: var(--text-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-item-status.success {
|
||||
background: oklch(from #00B87A l c h / 0.2);
|
||||
color: #00B87A;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.result-item-status.failed {
|
||||
background: oklch(from var(--lora-error) l c h / 0.2);
|
||||
color: var(--lora-error);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.result-item-status.skipped {
|
||||
background: oklch(from var(--lora-warning) l c h / 0.2);
|
||||
color: var(--lora-warning);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.result-item-info {
|
||||
@@ -610,8 +604,8 @@
|
||||
}
|
||||
|
||||
.result-item-error {
|
||||
font-size: 0.8em;
|
||||
color: var(--lora-error);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-error);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@@ -661,11 +655,11 @@
|
||||
|
||||
/* Completed State */
|
||||
.batch-progress-container.completed .progress-bar {
|
||||
background: #00B87A;
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.batch-progress-container.completed .status-icon {
|
||||
color: #00B87A;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.batch-progress-container.completed .status-icon i {
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
position: sticky; /* Keep the sticky position */
|
||||
top: var(--space-1);
|
||||
width: 100%;
|
||||
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1); /* Use accent color with low opacity */
|
||||
background-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.1); /* Use accent color with low opacity */
|
||||
color: var(--text-color);
|
||||
border-top: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3); /* Add top border with accent color */
|
||||
border-bottom: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4); /* Make bottom border stronger */
|
||||
border-top: 1px solid oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.3); /* Add top border with accent color */
|
||||
border-bottom: 1px solid oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.4); /* Make bottom border stronger */
|
||||
z-index: var(--z-overlay);
|
||||
padding: 12px 0;
|
||||
box-shadow: var(--shadow-lg); /* Stronger shadow */
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
.duplicates-banner i.fa-exclamation-triangle {
|
||||
font-size: 18px;
|
||||
color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||
color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||
}
|
||||
|
||||
.duplicates-banner .banner-actions {
|
||||
@@ -70,7 +70,7 @@
|
||||
|
||||
.duplicates-banner button.btn-exit-mode:hover {
|
||||
background-color: var(--bg-color);
|
||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
||||
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
}
|
||||
|
||||
.duplicates-banner button:hover {
|
||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
||||
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
background: var(--bg-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
@@ -117,7 +117,7 @@
|
||||
/* Duplicate groups */
|
||||
.duplicate-group {
|
||||
position: relative;
|
||||
border: 2px solid oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||
border: 2px solid oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
@@ -152,7 +152,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-left: 4px solid oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); /* Add accent border on the left */
|
||||
border-left: 4px solid oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h)); /* Add accent border on the left */
|
||||
}
|
||||
|
||||
.duplicate-group-header span:last-child {
|
||||
@@ -180,7 +180,7 @@
|
||||
}
|
||||
|
||||
.duplicate-group-header button:hover {
|
||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
||||
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
background: var(--bg-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
@@ -235,7 +235,7 @@
|
||||
}
|
||||
|
||||
.group-toggle-btn:hover {
|
||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var (--lora-accent-h);
|
||||
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
@@ -247,16 +247,16 @@
|
||||
}
|
||||
|
||||
.model-card.duplicate:hover {
|
||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
||||
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
}
|
||||
|
||||
.model-card.duplicate.latest {
|
||||
border-style: solid;
|
||||
border-color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||
border-color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||
}
|
||||
|
||||
.model-card.duplicate-selected {
|
||||
border: 2px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
||||
border: 2px solid oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
||||
background: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
@@ -328,7 +328,7 @@
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||
color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||
font-weight: bold;
|
||||
word-break: break-all; /* Ensure long hashes wrap properly */
|
||||
}
|
||||
@@ -351,12 +351,12 @@
|
||||
}
|
||||
|
||||
.verification-badge.verified {
|
||||
background-color: oklch(70% 0.2 140); /* Green for verified */
|
||||
background-color: var(--color-success); /* Green for verified */
|
||||
color: white;
|
||||
}
|
||||
|
||||
.verification-badge.mismatch {
|
||||
background-color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||
background-color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -366,7 +366,7 @@
|
||||
|
||||
/* Hash Mismatch Styling */
|
||||
.model-card.duplicate.hash-mismatch {
|
||||
border: 2px dashed oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||
border: 2px dashed oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||
opacity: 0.85;
|
||||
position: relative;
|
||||
}
|
||||
@@ -380,8 +380,8 @@
|
||||
bottom: 0;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h) / 0.05),
|
||||
oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h) / 0.05) 10px,
|
||||
oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h) / 0.05),
|
||||
oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h) / 0.05) 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
);
|
||||
@@ -398,7 +398,7 @@
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px; /* Changed from right:10px to left:10px */
|
||||
background: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||
background: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
padding: 3px 8px;
|
||||
@@ -417,7 +417,7 @@
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||
color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -437,7 +437,7 @@
|
||||
|
||||
.btn-verify-hashes:hover {
|
||||
background: var(--bg-color);
|
||||
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
||||
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -498,7 +498,7 @@
|
||||
|
||||
.help-icon:hover {
|
||||
opacity: 1;
|
||||
color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
||||
color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
}
|
||||
|
||||
/* Help tooltip */
|
||||
@@ -573,7 +573,7 @@
|
||||
/* In dark mode, add additional distinction */
|
||||
html[data-theme="dark"] .duplicates-banner {
|
||||
box-shadow: var(--shadow-dark-lg); /* Stronger shadow in dark mode */
|
||||
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15); /* Slightly stronger background in dark mode */
|
||||
background-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.15); /* Slightly stronger background in dark mode */
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .duplicate-group {
|
||||
@@ -598,11 +598,11 @@ html[data-theme="dark"] .help-tooltip {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.25);
|
||||
box-shadow: 0 0 0 2px oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.25);
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
#findDuplicatesBtn.active:hover {
|
||||
background: oklch(calc(var(--lora-accent-l) - 5%) var(--lora-accent-c) var(--lora-accent-h));
|
||||
background: oklch(calc(var(--color-accent-l) - 5%) var(--color-accent-c) var(--color-accent-h));
|
||||
}
|
||||
|
||||
@@ -283,7 +283,6 @@
|
||||
|
||||
.theme-toggle {
|
||||
position: relative;
|
||||
/* Ensure relative positioning for the container */
|
||||
}
|
||||
|
||||
.theme-toggle .light-icon,
|
||||
@@ -293,17 +292,14 @@
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
/* Center perfectly */
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* Default state shows dark icon */
|
||||
.theme-toggle .dark-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Light theme shows light icon */
|
||||
.theme-toggle.theme-light .light-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -313,7 +309,6 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Dark theme shows dark icon */
|
||||
.theme-toggle.theme-dark .dark-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -323,7 +318,6 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Auto theme shows auto icon */
|
||||
.theme-toggle.theme-auto .auto-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -333,6 +327,201 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.theme-popover {
|
||||
display: none;
|
||||
position: fixed;
|
||||
background: var(--surface-base, #ffffff);
|
||||
border: 1px solid var(--border-base, #e0e0e0);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
box-shadow: var(--shadow-xl, 0 4px 16px rgba(0, 0, 0, 0.15));
|
||||
padding: 12px;
|
||||
min-width: 220px;
|
||||
z-index: calc(var(--z-overlay) + 1);
|
||||
animation: theme-popover-in 0.15s ease-out;
|
||||
}
|
||||
|
||||
.theme-popover.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes theme-popover-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-popover-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.theme-popover-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary, #6c757d);
|
||||
}
|
||||
|
||||
.theme-popover-divider {
|
||||
height: 1px;
|
||||
background: var(--border-base, #e0e0e0);
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.theme-popover-modes {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.theme-mode-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 4px;
|
||||
border: 1px solid var(--border-base, #e0e0e0);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background: var(--surface-elevated, #ffffff);
|
||||
color: var(--text-primary, #333333);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
transition: background-color var(--transition-base, 200ms ease),
|
||||
border-color var(--transition-base, 200ms ease),
|
||||
color var(--transition-base, 200ms ease);
|
||||
}
|
||||
|
||||
.theme-mode-btn i {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.theme-mode-btn:hover {
|
||||
background: var(--surface-hover, oklch(95% 0.02 256));
|
||||
border-color: var(--color-accent, oklch(68% 0.28 256));
|
||||
}
|
||||
|
||||
.theme-mode-btn.active {
|
||||
background: var(--color-accent-subtle, oklch(68% 0.28 256 / 0.12));
|
||||
border-color: var(--color-accent, oklch(68% 0.28 256));
|
||||
color: var(--color-accent, oklch(68% 0.28 256));
|
||||
}
|
||||
|
||||
.theme-popover-presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.theme-preset-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 4px;
|
||||
border: 1px solid var(--border-base, #e0e0e0);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background: var(--surface-elevated, #ffffff);
|
||||
color: var(--text-primary, #333333);
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
transition: background-color var(--transition-base, 200ms ease),
|
||||
border-color var(--transition-base, 200ms ease),
|
||||
color var(--transition-base, 200ms ease);
|
||||
}
|
||||
|
||||
.theme-preset-btn:hover {
|
||||
background: var(--surface-hover, oklch(95% 0.02 256));
|
||||
border-color: var(--color-accent, oklch(68% 0.28 256));
|
||||
}
|
||||
|
||||
.theme-preset-btn.active {
|
||||
background: var(--color-accent-subtle, oklch(68% 0.28 256 / 0.12));
|
||||
border-color: var(--color-accent, oklch(68% 0.28 256));
|
||||
color: var(--color-accent, oklch(68% 0.28 256));
|
||||
}
|
||||
|
||||
.preset-swatch {
|
||||
display: inline-block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--radius-xs, 4px);
|
||||
border: 1px solid var(--border-subtle, oklch(72% 0.03 256 / 0.45));
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-base, 200ms ease),
|
||||
box-shadow var(--transition-base, 200ms ease);
|
||||
}
|
||||
|
||||
/* Solid accent colors — each swatch shows the theme's accent color directly.
|
||||
This matches the app's flat, token-driven design language instead of using
|
||||
decorative gradients that clash with the matte aesthetic. */
|
||||
|
||||
.preset-swatch-default {
|
||||
background: oklch(68% 0.28 256);
|
||||
}
|
||||
|
||||
.preset-swatch-nord {
|
||||
background: oklch(62% 0.18 213);
|
||||
}
|
||||
|
||||
.preset-swatch-midnight {
|
||||
background: oklch(52% 0.15 300);
|
||||
}
|
||||
|
||||
.preset-swatch-monokai {
|
||||
background: oklch(72% 0.24 190);
|
||||
}
|
||||
|
||||
.preset-swatch-dracula {
|
||||
background: oklch(68% 0.24 265);
|
||||
}
|
||||
|
||||
.preset-swatch-solarized {
|
||||
background: oklch(55% 0.18 175);
|
||||
}
|
||||
|
||||
.theme-preset-btn.active .preset-swatch {
|
||||
box-shadow: 0 0 0 2px var(--color-accent, oklch(68% 0.28 256));
|
||||
}
|
||||
|
||||
.theme-preset-btn:hover .preset-swatch {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
/* Dark mode: use each preset's dark-mode accent lightness for visibility.
|
||||
These match the --color-accent-l values from [data-theme="dark"][data-theme-preset="..."]
|
||||
in tokens/colors.css so the swatch accurately previews what the theme looks like. */
|
||||
|
||||
[data-theme="dark"] .preset-swatch-default {
|
||||
background: oklch(68% 0.28 256);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .preset-swatch-nord {
|
||||
background: oklch(68% 0.18 213);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .preset-swatch-midnight {
|
||||
background: oklch(68% 0.14 300);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .preset-swatch-monokai {
|
||||
background: oklch(72% 0.24 190);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .preset-swatch-dracula {
|
||||
background: oklch(72% 0.24 265);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .preset-swatch-solarized {
|
||||
background: oklch(60% 0.18 175);
|
||||
}
|
||||
|
||||
/* Badge styling */
|
||||
.update-badge {
|
||||
position: absolute;
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
|
||||
.lora-item.is-early-access {
|
||||
background: rgba(0, 184, 122, 0.05);
|
||||
border-left: 4px solid #00B87A;
|
||||
border-left: 4px solid var(--color-success);
|
||||
}
|
||||
|
||||
.lora-item.missing-locally {
|
||||
@@ -310,7 +310,7 @@
|
||||
|
||||
.missing-lora-item.is-early-access {
|
||||
background: rgba(0, 184, 122, 0.05);
|
||||
border-left: 3px solid #00B87A;
|
||||
border-left: 3px solid var(--color-success);
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
@@ -630,7 +630,7 @@
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0, 184, 122, 0.1);
|
||||
border: 1px solid #00B87A;
|
||||
border: 1px solid var(--color-success);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
margin-bottom: var(--space-2);
|
||||
@@ -646,7 +646,7 @@
|
||||
|
||||
/* Specific styling for the early access warning container in import modal */
|
||||
.early-access-warning .warning-icon {
|
||||
color: #00B87A;
|
||||
color: var(--color-success);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/* Keyboard navigation indicator and help */
|
||||
.keyboard-nav-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
cursor: help;
|
||||
transition: var(--transition-base);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.keyboard-nav-hint:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.keyboard-nav-hint i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Tooltip styling */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip .tooltiptext {
|
||||
visibility: hidden;
|
||||
width: 240px;
|
||||
background-color: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
text-align: center;
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 8px;
|
||||
position: absolute;
|
||||
z-index: 9999; /* Ensure tooltip appears above cards */
|
||||
right: 120%; /* Position tooltip to the left of the icon */
|
||||
top: 50%; /* Vertically center */
|
||||
transform: translateY(-15%); /* Vertically center */
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 1px solid var(--lora-border);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tooltip .tooltiptext::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%; /* Vertically center arrow */
|
||||
left: 100%; /* Arrow on the right side */
|
||||
margin-top: -5px;
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent transparent var(--lora-border); /* Arrow points right */
|
||||
}
|
||||
|
||||
.tooltip:hover .tooltiptext {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Keyboard shortcuts table */
|
||||
.keyboard-shortcuts {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.keyboard-shortcuts td {
|
||||
padding: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.keyboard-shortcuts td:first-child {
|
||||
font-weight: bold;
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.key {
|
||||
display: inline-block;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-size: 0.8em;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
@@ -72,6 +72,10 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.modal-header-actions .license-permissions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.license-restrictions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -95,6 +99,41 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Set 2 — New style permission indicators */
|
||||
.license-permissions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.license-icon-new {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-block;
|
||||
border-radius: 4px;
|
||||
background-color: var(--text-muted);
|
||||
-webkit-mask: var(--license-icon-image) center/contain no-repeat;
|
||||
mask: var(--license-icon-image) center/contain no-repeat;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
cursor: default;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.license-icon-new.allowed {
|
||||
background-color: var(--color-success, #40c057);
|
||||
outline-color: color-mix(in oklch, var(--color-success, #40c057) 30%, transparent);
|
||||
}
|
||||
|
||||
.license-icon-new.denied {
|
||||
background-color: var(--color-error, #fa5252);
|
||||
outline-color: color-mix(in oklch, var(--color-error, #fa5252) 30%, transparent);
|
||||
}
|
||||
|
||||
.license-icon-new:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Info Grid */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -24,11 +24,6 @@
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.stat-card > i {
|
||||
font-size: 1.25em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -52,40 +47,20 @@
|
||||
border-left-color: var(--color-success);
|
||||
}
|
||||
|
||||
.stat-card-success > i {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.stat-card-failure {
|
||||
border-left-color: var(--color-error);
|
||||
}
|
||||
|
||||
.stat-card-failure > i {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.stat-card-skipped {
|
||||
border-left-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.stat-card-skipped > i {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.stat-card-total {
|
||||
border-left-color: var(--color-info);
|
||||
}
|
||||
|
||||
.stat-card-total > i {
|
||||
color: var(--color-info);
|
||||
border-left-color: var(--lora-border);
|
||||
}
|
||||
|
||||
.stat-card-time {
|
||||
border-left-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.stat-card-time > i {
|
||||
color: var(--color-accent);
|
||||
border-left-color: var(--lora-border);
|
||||
}
|
||||
|
||||
.refresh-failures-section {
|
||||
@@ -122,7 +97,7 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--lora-surface);
|
||||
border-bottom: 2px solid var(--lora-border);
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
text-align: left;
|
||||
font-weight: var(--weight-semibold);
|
||||
|
||||
@@ -335,7 +335,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* API key input specific styles */
|
||||
/* API key input — CSS masking (prevents Chrome password manager triggers) */
|
||||
.api-key-masked {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
|
||||
/* API key input specific styles (shared with proxy password) */
|
||||
.api-key-input {
|
||||
width: 100%; /* Take full width of parent */
|
||||
position: relative;
|
||||
@@ -345,7 +350,7 @@
|
||||
|
||||
.api-key-input input {
|
||||
width: 100%;
|
||||
padding: 6px 40px 6px 10px; /* Add left padding */
|
||||
padding: 6px 40px 6px 10px; /* Right padding for eye button */
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--border-radius-xs);
|
||||
@@ -353,6 +358,13 @@
|
||||
background-color: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
font-size: 0.95em;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.api-key-input input:focus {
|
||||
border-color: var(--lora-accent);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
|
||||
}
|
||||
|
||||
.api-key-input .toggle-visibility {
|
||||
@@ -364,12 +376,98 @@
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.api-key-input .toggle-visibility:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* API key item — stack status/edit views vertically for smooth cross-fade */
|
||||
.api-key-item .setting-control {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* API key status display (shown when not editing) */
|
||||
.api-key-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
|
||||
max-height: 80px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.api-key-status.is-hidden {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateY(-4px);
|
||||
pointer-events: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.api-key-status-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.95em;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Status color modifiers — replace inline styles */
|
||||
.api-key-status--configured .fa-check-circle {
|
||||
color: var(--lora-success);
|
||||
}
|
||||
|
||||
.api-key-status--unconfigured .fa-times-circle {
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
/* Utility classes for status icon colors (used by JS) */
|
||||
.text-success {
|
||||
color: var(--lora-success);
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
/* API key inline edit container — flex row with input + buttons */
|
||||
.api-key-edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
|
||||
max-height: 80px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.api-key-edit.is-hidden {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateY(-4px);
|
||||
pointer-events: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.api-key-edit .api-key-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.api-key-edit .primary-btn,
|
||||
.api-key-edit .secondary-btn {
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Text input wrapper styles for consistent input styling */
|
||||
.text-input-wrapper {
|
||||
width: 100%;
|
||||
@@ -823,54 +921,107 @@
|
||||
}
|
||||
|
||||
.range-control input[type="range"] {
|
||||
--range-fill: 40%;
|
||||
width: 120px;
|
||||
height: 4px;
|
||||
height: 6px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--lora-accent) 0%,
|
||||
var(--lora-accent) var(--range-fill),
|
||||
var(--border-color) var(--range-fill),
|
||||
var(--border-color) 100%
|
||||
);
|
||||
border-radius: var(--radius-full);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.range-control input[type="range"]:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.range-control input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--lora-accent);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--lora-surface);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.15s ease;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: transform var(--transition-bounce), box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.range-control input[type="range"]::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.15);
|
||||
transform: scale(1.2);
|
||||
box-shadow: var(--shadow-md), 0 0 0 4px var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.range-control input[type="range"]::-webkit-slider-thumb:active {
|
||||
transform: scale(1.1);
|
||||
box-shadow: var(--shadow-md), 0 0 0 6px var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.range-control input[type="range"]:focus-visible::-webkit-slider-thumb {
|
||||
box-shadow: var(--shadow-md), 0 0 0 3px var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.range-control input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--lora-accent);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--lora-surface);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: transform var(--transition-bounce), box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.range-control input[type="range"]::-moz-range-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
box-shadow: var(--shadow-md), 0 0 0 4px var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.range-control input[type="range"]::-moz-range-thumb:active {
|
||||
transform: scale(1.1);
|
||||
box-shadow: var(--shadow-md), 0 0 0 6px var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.range-control input[type="range"]::-moz-range-track {
|
||||
height: 6px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.range-control .range-value {
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
font-size: 0.85em;
|
||||
font-weight: 700;
|
||||
color: var(--lora-accent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: var(--surface-subtle);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .range-control input[type="range"] {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--lora-accent) 0%,
|
||||
var(--lora-accent) var(--range-fill),
|
||||
rgba(255, 255, 255, 0.15) var(--range-fill),
|
||||
rgba(255, 255, 255, 0.15) 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .range-control input[type="range"]::-moz-range-track {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
|
||||
@@ -114,11 +114,10 @@
|
||||
.sidebar-hidden-indicator {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
top: 68px; /* Align with sidebar header */
|
||||
z-index: var(--z-overlay);
|
||||
width: 14px;
|
||||
height: 44px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -135,7 +134,7 @@
|
||||
}
|
||||
|
||||
.sidebar-hidden-indicator i {
|
||||
font-size: 9px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
@@ -144,6 +143,21 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Subtle breathing animation for first-time discovery */
|
||||
@keyframes sidebarBreathing {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.65; }
|
||||
}
|
||||
|
||||
.sidebar-hidden-indicator.breathing {
|
||||
animation: sidebarBreathing 2.5s ease-in-out infinite;
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
.sidebar-hidden-indicator.breathing:hover {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.sidebar-hidden-indicator-tooltip {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
transition: var(--transition-slow);
|
||||
/* Add glow effect */
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(24, 144, 255, 0.3),
|
||||
0 0 20px rgba(24, 144, 255, 0.2),
|
||||
0 0 0 2px color-mix(in oklch, var(--color-accent) 30%, transparent),
|
||||
0 0 20px color-mix(in oklch, var(--color-accent) 20%, transparent),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@@ -221,14 +221,14 @@
|
||||
@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),
|
||||
0 0 0 2px color-mix(in oklch, var(--color-accent) 40%, transparent),
|
||||
0 0 20px color-mix(in oklch, var(--color-accent) 30%, transparent),
|
||||
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),
|
||||
0 0 0 4px color-mix(in oklch, var(--color-accent) 60%, transparent),
|
||||
0 0 30px color-mix(in oklch, var(--color-accent) 40%, transparent),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
@import 'components/initialization.css';
|
||||
@import 'components/progress-panel.css';
|
||||
@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 */
|
||||
@import 'components/media-viewer.css';
|
||||
|
||||
@@ -37,13 +37,13 @@
|
||||
--color-error-border: color-mix(in oklch, var(--color-error) 50%, transparent);
|
||||
|
||||
--color-info: oklch(var(--color-info-l) var(--color-info-c) var(--color-info-h));
|
||||
--color-info-bg: oklch(72% 0.2 220);
|
||||
--color-info-text: oklch(28% 0.03 220);
|
||||
--color-info-glow: oklch(72% 0.2 220 / 0.28);
|
||||
--color-info-bg: oklch(var(--color-info-l) var(--color-info-c) var(--color-info-h));
|
||||
--color-info-text: oklch(28% 0.03 var(--color-info-h));
|
||||
--color-info-glow: oklch(var(--color-info-l) var(--color-info-c) var(--color-info-h) / 0.28);
|
||||
|
||||
--color-skip-refresh-bg: oklch(82% 0.12 45);
|
||||
--color-skip-refresh-text: oklch(35% 0.02 45);
|
||||
--color-skip-refresh-glow: oklch(82% 0.12 45 / 0.15);
|
||||
--color-skip-refresh-bg: oklch(82% 0.12 var(--color-warning-h));
|
||||
--color-skip-refresh-text: oklch(35% 0.02 var(--color-warning-h));
|
||||
--color-skip-refresh-glow: oklch(82% 0.12 var(--color-warning-h) / 0.15);
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -106,12 +106,360 @@
|
||||
--status-info-bg: oklch(50% 0.10 190 / 0.25);
|
||||
--status-info-border: oklch(55% 0.12 195 / 0.3);
|
||||
|
||||
--color-info-bg: oklch(62% 0.18 220);
|
||||
--color-info-text: oklch(98% 0.02 240);
|
||||
--color-info-glow: oklch(62% 0.18 220 / 0.4);
|
||||
--color-info-bg: oklch(62% 0.18 var(--color-info-h));
|
||||
--color-info-text: oklch(98% 0.02 var(--color-info-h));
|
||||
--color-info-glow: oklch(62% 0.18 var(--color-info-h) / 0.4);
|
||||
|
||||
--color-error-bg: color-mix(in oklch, var(--color-error) 15%, transparent);
|
||||
--color-error-border: color-mix(in oklch, var(--color-error) 40%, transparent);
|
||||
|
||||
--favorite-color: #ffc107;
|
||||
}
|
||||
|
||||
/* ── Preset: Nord ──────────────────────────────────────────── */
|
||||
|
||||
[data-theme-preset="nord"] {
|
||||
--color-accent-h: 213;
|
||||
--color-accent-c: 0.18;
|
||||
--color-accent-l: 62%;
|
||||
--color-warning-h: 35;
|
||||
--color-warning-c: 0.18;
|
||||
--color-success-h: 130;
|
||||
--color-error-l: 62%;
|
||||
--color-error-c: 0.22;
|
||||
--color-error-h: 5;
|
||||
--color-info-h: 195;
|
||||
--color-info-c: 0.18;
|
||||
|
||||
--bg-base: oklch(96% 0.01 240);
|
||||
--bg-elevated: oklch(98% 0.008 240 / 0.95);
|
||||
--bg-hover: oklch(93% 0.02 240);
|
||||
--bg-disabled: oklch(92% 0.01 240);
|
||||
|
||||
--text-primary: oklch(22% 0.03 260);
|
||||
--text-secondary: oklch(48% 0.03 260);
|
||||
--text-inverse: oklch(97% 0.01 240);
|
||||
|
||||
--surface-base: oklch(97% 0.01 240);
|
||||
--surface-elevated: oklch(98% 0.008 240 / 0.95);
|
||||
--surface-hover: oklch(93% 0.02 240);
|
||||
--surface-subtle: oklch(0% 0 0 / 0.03);
|
||||
|
||||
--border-base: oklch(82% 0.03 240);
|
||||
--border-subtle: oklch(82% 0.03 240 / 0.45);
|
||||
|
||||
--favorite-color: oklch(72% 0.14 85);
|
||||
--favorite-glow: oklch(72% 0.14 85 / 0.5);
|
||||
}
|
||||
|
||||
[data-theme="dark"][data-theme-preset="nord"] {
|
||||
--color-accent-h: 213;
|
||||
--color-accent-c: 0.18;
|
||||
--color-accent-l: 68%;
|
||||
--color-warning-h: 35;
|
||||
--color-warning-c: 0.18;
|
||||
--color-success-h: 130;
|
||||
--color-error-l: 65%;
|
||||
--color-error-c: 0.22;
|
||||
--color-error-h: 5;
|
||||
--color-info-h: 195;
|
||||
--color-info-c: 0.18;
|
||||
|
||||
--bg-base: oklch(20% 0.03 260);
|
||||
--bg-elevated: oklch(24% 0.03 260 / 0.98);
|
||||
--bg-hover: oklch(30% 0.03 260);
|
||||
--bg-disabled: oklch(30% 0.02 260);
|
||||
|
||||
--text-primary: oklch(87% 0.02 240);
|
||||
--text-secondary: oklch(68% 0.02 240);
|
||||
--text-inverse: oklch(20% 0.03 260);
|
||||
|
||||
--surface-base: oklch(26% 0.03 260);
|
||||
--surface-elevated: oklch(24% 0.03 260 / 0.98);
|
||||
--surface-hover: oklch(30% 0.03 260);
|
||||
--surface-subtle: oklch(100% 0 0 / 0.03);
|
||||
|
||||
--border-base: oklch(38% 0.03 260);
|
||||
--border-subtle: oklch(87% 0.02 240 / 0.15);
|
||||
|
||||
--favorite-color: oklch(78% 0.15 85);
|
||||
--favorite-glow: oklch(78% 0.15 85 / 0.5);
|
||||
}
|
||||
|
||||
/* ── Preset: Midnight ───────────────────────────────────────── */
|
||||
|
||||
[data-theme-preset="midnight"] {
|
||||
--color-accent-h: 300;
|
||||
--color-accent-c: 0.15;
|
||||
--color-accent-l: 52%;
|
||||
--color-warning-h: 50;
|
||||
--color-warning-c: 0.18;
|
||||
--color-success-h: 135;
|
||||
--color-error-h: 5;
|
||||
--color-error-l: 62%;
|
||||
--color-error-c: 0.22;
|
||||
--color-info-h: 195;
|
||||
--color-info-c: 0.12;
|
||||
|
||||
--bg-base: oklch(96% 0.01 255);
|
||||
--bg-elevated: oklch(98% 0.008 255 / 0.95);
|
||||
--bg-hover: oklch(93% 0.02 255);
|
||||
--bg-disabled: oklch(92% 0.01 255);
|
||||
|
||||
--text-primary: oklch(22% 0.03 260);
|
||||
--text-secondary: oklch(48% 0.03 260);
|
||||
--text-inverse: oklch(97% 0.01 255);
|
||||
|
||||
--surface-base: oklch(97% 0.01 255);
|
||||
--surface-elevated: oklch(98% 0.008 255 / 0.95);
|
||||
--surface-hover: oklch(93% 0.02 255);
|
||||
--surface-subtle: oklch(0% 0 0 / 0.03);
|
||||
|
||||
--border-base: oklch(80% 0.03 255);
|
||||
--border-subtle: oklch(80% 0.03 255 / 0.45);
|
||||
|
||||
--favorite-color: oklch(72% 0.16 85);
|
||||
--favorite-glow: oklch(72% 0.16 85 / 0.5);
|
||||
}
|
||||
|
||||
[data-theme="dark"][data-theme-preset="midnight"] {
|
||||
--color-accent-h: 300;
|
||||
--color-accent-c: 0.14;
|
||||
--color-accent-l: 68%;
|
||||
--color-warning-h: 50;
|
||||
--color-warning-c: 0.18;
|
||||
--color-success-h: 135;
|
||||
--color-error-h: 5;
|
||||
--color-error-l: 65%;
|
||||
--color-error-c: 0.22;
|
||||
--color-info-h: 195;
|
||||
--color-info-c: 0.12;
|
||||
|
||||
--bg-base: oklch(18% 0.03 260);
|
||||
--bg-elevated: oklch(22% 0.03 260 / 0.98);
|
||||
--bg-hover: oklch(28% 0.03 260);
|
||||
--bg-disabled: oklch(28% 0.02 260);
|
||||
|
||||
--text-primary: oklch(88% 0.02 255);
|
||||
--text-secondary: oklch(68% 0.02 255);
|
||||
--text-inverse: oklch(18% 0.03 260);
|
||||
|
||||
--surface-base: oklch(24% 0.03 260);
|
||||
--surface-elevated: oklch(22% 0.03 260 / 0.98);
|
||||
--surface-hover: oklch(28% 0.03 260);
|
||||
--surface-subtle: oklch(100% 0 0 / 0.03);
|
||||
|
||||
--border-base: oklch(36% 0.03 260);
|
||||
--border-subtle: oklch(88% 0.02 255 / 0.15);
|
||||
|
||||
--favorite-color: oklch(78% 0.16 85);
|
||||
--favorite-glow: oklch(78% 0.16 85 / 0.5);
|
||||
}
|
||||
|
||||
/* ── Preset: Monokai ───────────────────────────────────────── */
|
||||
|
||||
[data-theme-preset="monokai"] {
|
||||
--color-accent-h: 190;
|
||||
--color-accent-c: 0.24;
|
||||
--color-accent-l: 72%;
|
||||
--color-warning-h: 50;
|
||||
--color-warning-c: 0.22;
|
||||
--color-success-h: 140;
|
||||
--color-error-l: 60%;
|
||||
--color-error-c: 0.22;
|
||||
--color-error-h: 340;
|
||||
--color-info-h: 250;
|
||||
|
||||
--bg-base: oklch(96% 0.01 80);
|
||||
--bg-elevated: oklch(98% 0.005 80 / 0.95);
|
||||
--bg-hover: oklch(93% 0.015 80);
|
||||
--bg-disabled: oklch(92% 0.01 80);
|
||||
|
||||
--text-primary: oklch(20% 0.02 100);
|
||||
--text-secondary: oklch(45% 0.02 100);
|
||||
--text-inverse: oklch(97% 0.01 80);
|
||||
|
||||
--surface-base: oklch(97% 0.008 80);
|
||||
--surface-elevated: oklch(98% 0.005 80 / 0.95);
|
||||
--surface-hover: oklch(93% 0.015 80);
|
||||
--surface-subtle: oklch(0% 0 0 / 0.03);
|
||||
|
||||
--border-base: oklch(80% 0.02 80);
|
||||
--border-subtle: oklch(80% 0.02 80 / 0.45);
|
||||
|
||||
--favorite-color: oklch(72% 0.16 85);
|
||||
--favorite-glow: oklch(72% 0.16 85 / 0.5);
|
||||
}
|
||||
|
||||
[data-theme="dark"][data-theme-preset="monokai"] {
|
||||
--color-accent-h: 190;
|
||||
--color-accent-c: 0.24;
|
||||
--color-accent-l: 72%;
|
||||
--color-warning-h: 50;
|
||||
--color-warning-c: 0.22;
|
||||
--color-success-h: 140;
|
||||
--color-error-l: 65%;
|
||||
--color-error-c: 0.22;
|
||||
--color-error-h: 340;
|
||||
--color-info-h: 250;
|
||||
|
||||
--bg-base: oklch(18% 0.02 100);
|
||||
--bg-elevated: oklch(22% 0.02 100 / 0.98);
|
||||
--bg-hover: oklch(28% 0.025 100);
|
||||
--bg-disabled: oklch(28% 0.015 100);
|
||||
|
||||
--text-primary: oklch(90% 0.02 80);
|
||||
--text-secondary: oklch(70% 0.02 80);
|
||||
--text-inverse: oklch(18% 0.02 100);
|
||||
|
||||
--surface-base: oklch(24% 0.02 100);
|
||||
--surface-elevated: oklch(22% 0.02 100 / 0.98);
|
||||
--surface-hover: oklch(28% 0.025 100);
|
||||
--surface-subtle: oklch(100% 0 0 / 0.03);
|
||||
|
||||
--border-base: oklch(36% 0.02 100);
|
||||
--border-subtle: oklch(90% 0.02 80 / 0.15);
|
||||
|
||||
--favorite-color: oklch(78% 0.16 85);
|
||||
--favorite-glow: oklch(78% 0.16 85 / 0.5);
|
||||
}
|
||||
|
||||
/* ── Preset: Dracula ───────────────────────────────────────── */
|
||||
|
||||
[data-theme-preset="dracula"] {
|
||||
--color-accent-h: 265;
|
||||
--color-accent-c: 0.24;
|
||||
--color-accent-l: 68%;
|
||||
--color-warning-h: 45;
|
||||
--color-warning-c: 0.22;
|
||||
--color-success-h: 135;
|
||||
--color-error-l: 62%;
|
||||
--color-error-c: 0.22;
|
||||
--color-error-h: 350;
|
||||
--color-info-h: 195;
|
||||
|
||||
--bg-base: oklch(96% 0.01 290);
|
||||
--bg-elevated: oklch(98% 0.008 290 / 0.95);
|
||||
--bg-hover: oklch(93% 0.02 290);
|
||||
--bg-disabled: oklch(92% 0.01 290);
|
||||
|
||||
--text-primary: oklch(22% 0.04 290);
|
||||
--text-secondary: oklch(48% 0.04 290);
|
||||
--text-inverse: oklch(97% 0.01 290);
|
||||
|
||||
--surface-base: oklch(97% 0.01 290);
|
||||
--surface-elevated: oklch(98% 0.008 290 / 0.95);
|
||||
--surface-hover: oklch(93% 0.02 290);
|
||||
--surface-subtle: oklch(0% 0 0 / 0.03);
|
||||
|
||||
--border-base: oklch(80% 0.04 290);
|
||||
--border-subtle: oklch(80% 0.04 290 / 0.45);
|
||||
|
||||
--favorite-color: oklch(72% 0.16 85);
|
||||
--favorite-glow: oklch(72% 0.16 85 / 0.5);
|
||||
}
|
||||
|
||||
[data-theme="dark"][data-theme-preset="dracula"] {
|
||||
--color-accent-h: 265;
|
||||
--color-accent-c: 0.24;
|
||||
--color-accent-l: 72%;
|
||||
--color-warning-h: 45;
|
||||
--color-warning-c: 0.22;
|
||||
--color-success-h: 135;
|
||||
--color-error-l: 65%;
|
||||
--color-error-c: 0.22;
|
||||
--color-error-h: 350;
|
||||
--color-info-h: 195;
|
||||
|
||||
--bg-base: oklch(18% 0.04 290);
|
||||
--bg-elevated: oklch(22% 0.04 290 / 0.98);
|
||||
--bg-hover: oklch(28% 0.04 290);
|
||||
--bg-disabled: oklch(28% 0.03 290);
|
||||
|
||||
--text-primary: oklch(90% 0.02 290);
|
||||
--text-secondary: oklch(70% 0.03 290);
|
||||
--text-inverse: oklch(18% 0.04 290);
|
||||
|
||||
--surface-base: oklch(24% 0.04 290);
|
||||
--surface-elevated: oklch(22% 0.04 290 / 0.98);
|
||||
--surface-hover: oklch(28% 0.04 290);
|
||||
--surface-subtle: oklch(100% 0 0 / 0.03);
|
||||
|
||||
--border-base: oklch(36% 0.04 290);
|
||||
--border-subtle: oklch(90% 0.02 290 / 0.15);
|
||||
|
||||
--favorite-color: oklch(78% 0.16 85);
|
||||
--favorite-glow: oklch(78% 0.16 85 / 0.5);
|
||||
}
|
||||
|
||||
/* ── Preset: Solarized ─────────────────────────────────────── */
|
||||
|
||||
[data-theme-preset="solarized"] {
|
||||
--color-accent-h: 175;
|
||||
--color-accent-c: 0.18;
|
||||
--color-accent-l: 55%;
|
||||
--color-warning-h: 45;
|
||||
--color-warning-c: 0.20;
|
||||
--color-success-h: 68;
|
||||
--color-error-l: 62%;
|
||||
--color-error-c: 0.22;
|
||||
--color-error-h: 5;
|
||||
--color-info-h: 220;
|
||||
--color-info-c: 0.16;
|
||||
--color-info-l: 68%;
|
||||
|
||||
--bg-base: oklch(95% 0.03 85);
|
||||
--bg-elevated: oklch(97% 0.025 85 / 0.95);
|
||||
--bg-hover: oklch(91% 0.035 85);
|
||||
--bg-disabled: oklch(90% 0.025 85);
|
||||
|
||||
--text-primary: oklch(30% 0.06 200);
|
||||
--text-secondary: oklch(50% 0.04 200);
|
||||
--text-inverse: oklch(95% 0.03 85);
|
||||
|
||||
--surface-base: oklch(96% 0.025 85);
|
||||
--surface-elevated: oklch(97% 0.025 85 / 0.95);
|
||||
--surface-hover: oklch(91% 0.035 85);
|
||||
--surface-subtle: oklch(0% 0 0 / 0.03);
|
||||
|
||||
--border-base: oklch(78% 0.04 85);
|
||||
--border-subtle: oklch(78% 0.04 85 / 0.45);
|
||||
|
||||
--favorite-color: oklch(68% 0.16 75);
|
||||
--favorite-glow: oklch(68% 0.16 75 / 0.5);
|
||||
}
|
||||
|
||||
[data-theme="dark"][data-theme-preset="solarized"] {
|
||||
--color-accent-h: 175;
|
||||
--color-accent-c: 0.18;
|
||||
--color-accent-l: 60%;
|
||||
--color-warning-h: 45;
|
||||
--color-warning-c: 0.20;
|
||||
--color-success-h: 68;
|
||||
--color-error-l: 65%;
|
||||
--color-error-c: 0.22;
|
||||
--color-error-h: 5;
|
||||
--color-info-h: 220;
|
||||
--color-info-c: 0.16;
|
||||
--color-info-l: 68%;
|
||||
|
||||
--bg-base: oklch(18% 0.05 200);
|
||||
--bg-elevated: oklch(22% 0.05 200 / 0.98);
|
||||
--bg-hover: oklch(28% 0.05 200);
|
||||
--bg-disabled: oklch(28% 0.04 200);
|
||||
|
||||
--text-primary: oklch(72% 0.03 85);
|
||||
--text-secondary: oklch(62% 0.03 85);
|
||||
--text-inverse: oklch(18% 0.05 200);
|
||||
|
||||
--surface-base: oklch(24% 0.05 200);
|
||||
--surface-elevated: oklch(22% 0.05 200 / 0.98);
|
||||
--surface-hover: oklch(28% 0.05 200);
|
||||
--surface-subtle: oklch(100% 0 0 / 0.03);
|
||||
|
||||
--border-base: oklch(36% 0.04 200);
|
||||
--border-subtle: oklch(72% 0.03 85 / 0.15);
|
||||
|
||||
--favorite-color: oklch(72% 0.16 75);
|
||||
--favorite-glow: oklch(72% 0.16 75 / 0.5);
|
||||
}
|
||||
|
||||
1
static/images/tabler/brush.svg
Normal file
1
static/images/tabler/brush.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-brush"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 21v-4a4 4 0 1 1 4 4h-4" /><path d="M21 3a16 16 0 0 0 -12.8 10.2" /><path d="M21 3a16 16 0 0 1 -10.2 12.8" /><path d="M10.6 9a9 9 0 0 1 4.4 4.4" /></svg>
|
||||
|
After Width: | Height: | Size: 460 B |
1
static/images/tabler/currency-dollar.svg
Normal file
1
static/images/tabler/currency-dollar.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-currency-dollar"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M16.7 8a3 3 0 0 0 -2.7 -2h-4a3 3 0 0 0 0 6h4a3 3 0 0 1 0 6h-4a3 3 0 0 1 -2.7 -2" /><path d="M12 3v3m0 12v3" /></svg>
|
||||
|
After Width: | Height: | Size: 431 B |
1
static/images/tabler/git-merge.svg
Normal file
1
static/images/tabler/git-merge.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-git-merge"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 18a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M5 6a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M15 12a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M7 8l0 8" /><path d="M7 8a4 4 0 0 0 4 4h4" /></svg>
|
||||
|
After Width: | Height: | Size: 501 B |
1
static/images/tabler/license.svg
Normal file
1
static/images/tabler/license.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-license"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 21h-9a3 3 0 0 1 -3 -3v-1h10v2a2 2 0 0 0 4 0v-14a2 2 0 1 1 2 2h-2m2 -4h-11a3 3 0 0 0 -3 3v11" /><path d="M9 7l4 0" /><path d="M9 11l4 0" /></svg>
|
||||
|
After Width: | Height: | Size: 455 B |
1
static/images/tabler/user.svg
Normal file
1
static/images/tabler/user.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-user"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /></svg>
|
||||
|
After Width: | Height: | Size: 401 B |
@@ -133,6 +133,16 @@ export class BaseModelApiClient {
|
||||
pageState.hasMore = result.hasMore;
|
||||
pageState.currentPage = pageState.currentPage + 1;
|
||||
|
||||
// When resetting to page 1, scroll back to the top
|
||||
// This covers: folder selection, filter/sort/search changes,
|
||||
// favorites/update/excluded view toggles, alphabet filter, etc.
|
||||
if (resetPage) {
|
||||
const scrollContainer = document.querySelector('.page-content');
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (updateFolders) {
|
||||
sidebarManager.refresh();
|
||||
}
|
||||
@@ -679,39 +689,34 @@ export class BaseModelApiClient {
|
||||
<div class="modal-content metadata-refresh-result-modal">
|
||||
<button class="close" data-action="close-modal">×</button>
|
||||
|
||||
<h2><i class="fas fa-sync-alt"></i> ${translate('modals.metadataFetchSummary.title', {}, 'Metadata Fetch Summary')}</h2>
|
||||
<h2>${translate('modals.metadataFetchSummary.title', {}, 'Metadata Fetch Summary')}</h2>
|
||||
|
||||
<div class="refresh-summary-stats">
|
||||
<div class="stat-card stat-card-success">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statSuccess', {}, 'Success')}</span>
|
||||
<span class="stat-card-value">${success}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-failure">
|
||||
<i class="fas fa-times-circle"></i>
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statFailed', {}, 'Failed')}</span>
|
||||
<span class="stat-card-value">${failure_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-skipped">
|
||||
<i class="fas fa-forward"></i>
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statSkipped', {}, 'Skipped')}</span>
|
||||
<span class="stat-card-value">${skipped_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-total">
|
||||
<i class="fas fa-database"></i>
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statTotal', {}, 'Total Scanned')}</span>
|
||||
<span class="stat-card-value">${total || processed}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-time">
|
||||
<i class="fas fa-clock"></i>
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statDuration', {}, 'Duration')}</span>
|
||||
<span class="stat-card-value">${elapsed_seconds}s</span>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { updateService } from '../managers/UpdateService.js';
|
||||
import { toggleTheme } from '../utils/uiHelpers.js';
|
||||
import { toggleTheme, setPreset, CYCLE_ORDER, PRESET_NAMES } 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 { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { updateElementAttribute } from '../utils/i18nHelpers.js';
|
||||
import { renderSupporters } from '../services/supportersService.js';
|
||||
|
||||
@@ -47,25 +47,8 @@ export class HeaderManager {
|
||||
}
|
||||
|
||||
initializeCommonElements() {
|
||||
// Handle theme toggle
|
||||
const themeToggle = document.querySelector('.theme-toggle');
|
||||
if (themeToggle) {
|
||||
const currentTheme = getStorageItem('theme') || 'auto';
|
||||
themeToggle.classList.add(`theme-${currentTheme}`);
|
||||
this.initializeThemePopover();
|
||||
|
||||
// Use i18nHelpers to update themeToggle's title
|
||||
this.updateThemeTooltip(themeToggle, currentTheme);
|
||||
|
||||
themeToggle.addEventListener('click', async () => {
|
||||
if (typeof toggleTheme === 'function') {
|
||||
const newTheme = toggleTheme();
|
||||
// Use i18nHelpers to update themeToggle's title
|
||||
this.updateThemeTooltip(themeToggle, newTheme);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle settings toggle
|
||||
const settingsToggle = document.querySelector('.settings-toggle');
|
||||
if (settingsToggle) {
|
||||
settingsToggle.addEventListener('click', () => {
|
||||
@@ -74,22 +57,19 @@ export class HeaderManager {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle update toggle
|
||||
|
||||
const updateToggle = document.getElementById('updateToggleBtn');
|
||||
if (updateToggle) {
|
||||
updateToggle.addEventListener('click', () => {
|
||||
updateService.toggleUpdateModal();
|
||||
});
|
||||
}
|
||||
|
||||
// Handle support toggle
|
||||
|
||||
const supportToggle = document.getElementById('supportToggleBtn');
|
||||
if (supportToggle) {
|
||||
supportToggle.addEventListener('click', async () => {
|
||||
if (window.modalManager) {
|
||||
window.modalManager.toggleModal('supportModal');
|
||||
// Load supporters data when modal opens
|
||||
try {
|
||||
await renderSupporters();
|
||||
} catch (error) {
|
||||
@@ -99,41 +79,144 @@ export class HeaderManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Handle QR code toggle
|
||||
const qrToggle = document.getElementById('toggleQRCode');
|
||||
const qrContainer = document.getElementById('qrCodeContainer');
|
||||
|
||||
if (qrToggle && qrContainer) {
|
||||
qrToggle.addEventListener('click', function() {
|
||||
qrContainer.classList.toggle('show');
|
||||
qrToggle.classList.toggle('active');
|
||||
|
||||
const toggleText = qrToggle.querySelector('.toggle-text');
|
||||
if (qrContainer.classList.contains('show')) {
|
||||
toggleText.textContent = 'Hide WeChat QR Code';
|
||||
// Add small delay to ensure DOM is updated before scrolling
|
||||
setTimeout(() => {
|
||||
const supportModal = document.querySelector('.support-modal');
|
||||
if (supportModal) {
|
||||
supportModal.scrollTo({
|
||||
top: supportModal.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, 250);
|
||||
} else {
|
||||
toggleText.textContent = 'Show WeChat QR Code';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Hide search functionality on Statistics page
|
||||
this.updateHeaderForPage();
|
||||
|
||||
// Initialize hamburger menu for mobile
|
||||
if (qrToggle && qrContainer) {
|
||||
qrToggle.addEventListener('click', function () {
|
||||
qrContainer.classList.toggle('show');
|
||||
qrToggle.classList.toggle('active');
|
||||
|
||||
const toggleText = qrToggle.querySelector('.toggle-text');
|
||||
if (qrContainer.classList.contains('show')) {
|
||||
toggleText.textContent = 'Hide WeChat QR Code';
|
||||
setTimeout(() => {
|
||||
const supportModal = document.querySelector('.support-modal');
|
||||
if (supportModal) {
|
||||
supportModal.scrollTo({
|
||||
top: supportModal.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, 250);
|
||||
} else {
|
||||
toggleText.textContent = 'Show WeChat QR Code';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.updateHeaderForPage();
|
||||
this.initializeHamburgerMenu();
|
||||
}
|
||||
|
||||
initializeThemePopover() {
|
||||
const themeToggle = document.querySelector('.theme-toggle');
|
||||
const themePopover = document.getElementById('themePopover');
|
||||
if (!themeToggle || !themePopover) return;
|
||||
|
||||
const currentTheme = getStorageItem('theme') || 'auto';
|
||||
const currentPreset = getStorageItem('theme_preset') || 'default';
|
||||
themeToggle.classList.add(`theme-${currentTheme}`);
|
||||
this.updateThemeTooltip(themeToggle, currentTheme);
|
||||
this.updatePopoverActiveStates(currentTheme, currentPreset);
|
||||
|
||||
themeToggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = themePopover.classList.contains('active');
|
||||
this.closeAllPopovers();
|
||||
if (!isOpen) {
|
||||
this.positionThemePopover();
|
||||
themePopover.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
themePopover.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const modeBtn = e.target.closest('.theme-mode-btn');
|
||||
const presetBtn = e.target.closest('.theme-preset-btn');
|
||||
|
||||
if (modeBtn) {
|
||||
const mode = modeBtn.dataset.mode;
|
||||
this.setThemeMode(mode);
|
||||
} else if (presetBtn) {
|
||||
const preset = presetBtn.dataset.preset;
|
||||
this.setThemePreset(preset);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!themeToggle.contains(e.target) && !themePopover.contains(e.target)) {
|
||||
themePopover.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Reposition on resize while popover is active
|
||||
window.addEventListener('resize', () => {
|
||||
if (themePopover.classList.contains('active')) {
|
||||
this.positionThemePopover();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
closeAllPopovers() {
|
||||
const themePopover = document.getElementById('themePopover');
|
||||
if (themePopover) {
|
||||
themePopover.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
positionThemePopover() {
|
||||
const themeToggle = document.querySelector('.theme-toggle');
|
||||
const themePopover = document.getElementById('themePopover');
|
||||
if (!themeToggle || !themePopover) return;
|
||||
const rect = themeToggle.getBoundingClientRect();
|
||||
// Guard: toggle may be hidden on narrow viewports (≤950px CSS hides .header-controls)
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
themePopover.style.top = (rect.bottom + 8) + 'px';
|
||||
themePopover.style.right = (window.innerWidth - rect.right - 8) + 'px';
|
||||
}
|
||||
|
||||
setThemeMode(mode) {
|
||||
setStorageItem('theme', mode);
|
||||
const htmlElement = document.documentElement;
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
htmlElement.removeAttribute('data-theme');
|
||||
if (mode === 'dark' || (mode === 'auto' && prefersDark)) {
|
||||
htmlElement.setAttribute('data-theme', 'dark');
|
||||
document.body.dataset.theme = 'dark';
|
||||
} else {
|
||||
htmlElement.setAttribute('data-theme', 'light');
|
||||
document.body.dataset.theme = 'light';
|
||||
}
|
||||
const themeToggle = document.querySelector('.theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
|
||||
themeToggle.classList.add(`theme-${mode}`);
|
||||
this.updateThemeTooltip(themeToggle, mode);
|
||||
}
|
||||
this.updateHamburgerThemeIcon();
|
||||
this.updatePopoverActiveStates(mode, getStorageItem('theme_preset') || 'default');
|
||||
}
|
||||
|
||||
setThemePreset(preset) {
|
||||
setPreset(preset);
|
||||
this.updatePopoverActiveStates(getStorageItem('theme') || 'auto', preset);
|
||||
this.updateHamburgerThemeIcon();
|
||||
}
|
||||
|
||||
updatePopoverActiveStates(theme, preset) {
|
||||
const popover = document.getElementById('themePopover');
|
||||
if (!popover) return;
|
||||
|
||||
popover.querySelectorAll('.theme-mode-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.mode === theme);
|
||||
});
|
||||
|
||||
popover.querySelectorAll('.theme-preset-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.preset === preset);
|
||||
});
|
||||
}
|
||||
|
||||
initializeHamburgerMenu() {
|
||||
const hamburgerBtn = document.getElementById('hamburgerMenuBtn');
|
||||
const hamburgerDropdown = document.getElementById('hamburgerDropdown');
|
||||
@@ -188,7 +271,6 @@ export class HeaderManager {
|
||||
case 'theme':
|
||||
if (typeof toggleTheme === 'function') {
|
||||
const newTheme = toggleTheme();
|
||||
// Update theme toggle in header if it exists
|
||||
const themeToggle = document.querySelector('.theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
|
||||
@@ -196,6 +278,7 @@ export class HeaderManager {
|
||||
this.updateThemeTooltip(themeToggle, newTheme);
|
||||
}
|
||||
this.updateHamburgerThemeIcon();
|
||||
this.updatePopoverActiveStates(newTheme, getStorageItem('theme_preset') || 'default');
|
||||
}
|
||||
break;
|
||||
case 'settings':
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { bulkManager } from '../managers/BulkManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
||||
@@ -457,21 +457,69 @@ export class SidebarManager {
|
||||
|
||||
try {
|
||||
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
|
||||
let movedFiles = []; // Array of { original_file_path, new_file_path }
|
||||
|
||||
if (useBulkMove) {
|
||||
await this.apiClient.moveBulkModels(this.draggedFilePaths, destination);
|
||||
const results = await this.apiClient.moveBulkModels(this.draggedFilePaths, destination);
|
||||
movedFiles = (results || [])
|
||||
.filter(r => r.success)
|
||||
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
||||
} else {
|
||||
await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination);
|
||||
const result = await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination);
|
||||
if (result) {
|
||||
movedFiles.push({
|
||||
original_file_path: result.original_file_path || this.draggedFilePaths[0],
|
||||
new_file_path: result.new_file_path
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log('[SidebarManager] apiClient.move successful');
|
||||
|
||||
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') {
|
||||
console.log('[SidebarManager] calling resetAndReload');
|
||||
await this.pageControls.resetAndReload(true);
|
||||
} else {
|
||||
console.log('[SidebarManager] calling refresh');
|
||||
await this.refresh();
|
||||
// Update VirtualScroller in-place instead of full reload
|
||||
if (movedFiles.length > 0 && state.virtualScroller) {
|
||||
const pageState = getCurrentPageState();
|
||||
const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
|
||||
const isRecursive = pageState.searchOptions?.recursive ?? true;
|
||||
const isFolderFiltered = pageState.activeFolder !== null;
|
||||
|
||||
const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
|
||||
// Determine if items in the target folder are visible in the current view
|
||||
let itemsRemainVisible = true;
|
||||
if (isFolderFiltered) {
|
||||
if (isRecursive) {
|
||||
itemsRemainVisible = normalizedActive === '' ||
|
||||
normalizedTarget === normalizedActive ||
|
||||
normalizedTarget.startsWith(normalizedActive + '/');
|
||||
} else {
|
||||
itemsRemainVisible = normalizedTarget === normalizedActive;
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsRemainVisible) {
|
||||
// Items stay visible — update each item's file_path to reflect new location
|
||||
for (const moved of movedFiles) {
|
||||
if (moved.original_file_path && moved.new_file_path) {
|
||||
state.virtualScroller.updateSingleItem(moved.original_file_path, {
|
||||
file_path: moved.new_file_path,
|
||||
folder: normalizedTarget
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Items no longer visible in current folder — remove from VirtualScroller
|
||||
const pathsToRemove = movedFiles
|
||||
.map(m => m.original_file_path)
|
||||
.filter(Boolean);
|
||||
if (pathsToRemove.length > 0) {
|
||||
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh sidebar folder tree only (no model data reload)
|
||||
await this.refresh();
|
||||
|
||||
if (this.draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
|
||||
bulkManager.toggleBulkMode();
|
||||
}
|
||||
@@ -530,21 +578,69 @@ export class SidebarManager {
|
||||
|
||||
try {
|
||||
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
|
||||
let movedFiles = []; // Array of { original_file_path, new_file_path }
|
||||
|
||||
if (useBulkMove) {
|
||||
await this.apiClient.moveBulkModels(draggedFilePaths, destination);
|
||||
const results = await this.apiClient.moveBulkModels(draggedFilePaths, destination);
|
||||
movedFiles = (results || [])
|
||||
.filter(r => r.success)
|
||||
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
||||
} else {
|
||||
await this.apiClient.moveSingleModel(draggedFilePaths[0], destination);
|
||||
const result = await this.apiClient.moveSingleModel(draggedFilePaths[0], destination);
|
||||
if (result) {
|
||||
movedFiles.push({
|
||||
original_file_path: result.original_file_path || draggedFilePaths[0],
|
||||
new_file_path: result.new_file_path
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log('[SidebarManager] apiClient.move successful');
|
||||
|
||||
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') {
|
||||
console.log('[SidebarManager] calling resetAndReload');
|
||||
await this.pageControls.resetAndReload(true);
|
||||
} else {
|
||||
console.log('[SidebarManager] calling refresh');
|
||||
await this.refresh();
|
||||
// Update VirtualScroller in-place instead of full reload
|
||||
if (movedFiles.length > 0 && state.virtualScroller) {
|
||||
const pageState = getCurrentPageState();
|
||||
const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
|
||||
const isRecursive = pageState.searchOptions?.recursive ?? true;
|
||||
const isFolderFiltered = pageState.activeFolder !== null;
|
||||
|
||||
const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
|
||||
// Determine if items in the target folder are visible in the current view
|
||||
let itemsRemainVisible = true;
|
||||
if (isFolderFiltered) {
|
||||
if (isRecursive) {
|
||||
itemsRemainVisible = normalizedActive === '' ||
|
||||
normalizedTarget === normalizedActive ||
|
||||
normalizedTarget.startsWith(normalizedActive + '/');
|
||||
} else {
|
||||
itemsRemainVisible = normalizedTarget === normalizedActive;
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsRemainVisible) {
|
||||
// Items stay visible — update each item's file_path to reflect new location
|
||||
for (const moved of movedFiles) {
|
||||
if (moved.original_file_path && moved.new_file_path) {
|
||||
state.virtualScroller.updateSingleItem(moved.original_file_path, {
|
||||
file_path: moved.new_file_path,
|
||||
folder: normalizedTarget
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Items no longer visible in current folder — remove from VirtualScroller
|
||||
const pathsToRemove = movedFiles
|
||||
.map(m => m.original_file_path)
|
||||
.filter(Boolean);
|
||||
if (pathsToRemove.length > 0) {
|
||||
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh sidebar folder tree only (no model data reload)
|
||||
await this.refresh();
|
||||
|
||||
if (draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
|
||||
bulkManager.toggleBulkMode();
|
||||
}
|
||||
@@ -1040,7 +1136,15 @@ export class SidebarManager {
|
||||
<span class="sidebar-hidden-indicator-tooltip">${translate('sidebar.showSidebar')}</span>
|
||||
`;
|
||||
|
||||
// Subtle breathing animation on first sight to aid discoverability;
|
||||
// stops permanently after user clicks the restore button once
|
||||
const restoreKey = `${this.pageType}_restoreButtonUsed`;
|
||||
if (!getStorageItem(restoreKey, false)) {
|
||||
indicator.classList.add('breathing');
|
||||
}
|
||||
|
||||
indicator.addEventListener('click', () => {
|
||||
setStorageItem(restoreKey, true);
|
||||
this.toggleHideOnThisPage();
|
||||
});
|
||||
|
||||
@@ -1338,7 +1442,7 @@ export class SidebarManager {
|
||||
this.pageControls.pageState.activeFolder = normalizedPath;
|
||||
setStorageItem(`${this.pageType}_activeFolder`, normalizedPath);
|
||||
|
||||
// Reload models with new filter
|
||||
// Reload models with new filter (loadMoreWithVirtualScroll will scroll to top)
|
||||
await this.pageControls.resetAndReload();
|
||||
}
|
||||
|
||||
|
||||
@@ -234,6 +234,95 @@ function renderLicenseIcons(modelData) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Set 2 (new CivitAI-style) permission icons ──
|
||||
|
||||
const NEW_LICENSE_ICON_CONFIG = [
|
||||
{
|
||||
key: 'commercial',
|
||||
icon: 'currency-dollar.svg',
|
||||
allowedFn: (license) => {
|
||||
const uses = license.allowCommercialUse || [];
|
||||
return uses.includes('Image') || uses.includes('Sell');
|
||||
},
|
||||
labelAllowed: 'Commercial use allowed',
|
||||
labelDenied: 'No commercial use'
|
||||
},
|
||||
{
|
||||
key: 'genServices',
|
||||
icon: 'brush.svg',
|
||||
allowedFn: (license) => {
|
||||
const uses = license.allowCommercialUse || [];
|
||||
return uses.includes('RentCivit') || uses.includes('Rent');
|
||||
},
|
||||
labelAllowed: 'Generation services allowed',
|
||||
labelDenied: 'No generation services'
|
||||
},
|
||||
{
|
||||
key: 'credit',
|
||||
icon: 'user.svg',
|
||||
allowedFn: (license) => !!license.allowNoCredit,
|
||||
labelAllowed: 'No credit required',
|
||||
labelDenied: 'Creator credit required'
|
||||
},
|
||||
{
|
||||
key: 'derivatives',
|
||||
icon: 'git-merge.svg',
|
||||
allowedFn: (license) => !!license.allowDerivatives,
|
||||
labelAllowed: 'Merges allowed',
|
||||
labelDenied: 'No merges allowed'
|
||||
},
|
||||
{
|
||||
key: 'relicense',
|
||||
icon: 'license.svg',
|
||||
allowedFn: (license) => !!license.allowDifferentLicense,
|
||||
labelAllowed: 'Different permissions allowed on merges',
|
||||
labelDenied: 'Same permissions required on merges'
|
||||
}
|
||||
];
|
||||
|
||||
function createNewLicenseIconMarkup(icon, allowed, label) {
|
||||
const safeLabel = escapeAttribute(label);
|
||||
const iconPath = `/loras_static/images/tabler/${icon}`;
|
||||
const stateClass = allowed ? 'allowed' : 'denied';
|
||||
return `<span class="license-icon-new ${stateClass}" role="img" aria-label="${safeLabel}" title="${safeLabel}" style="--license-icon-image: url('${iconPath}')"></span>`;
|
||||
}
|
||||
|
||||
function renderNewLicenseIcons(modelData) {
|
||||
const license = modelData?.civitai?.model;
|
||||
if (!license) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const icons = [];
|
||||
NEW_LICENSE_ICON_CONFIG.forEach((config) => {
|
||||
if (config.key === 'credit' && !hasLicenseField(license, 'allowNoCredit')) {
|
||||
return;
|
||||
}
|
||||
if (config.key === 'derivatives' && !hasLicenseField(license, 'allowDerivatives')) {
|
||||
return;
|
||||
}
|
||||
if (config.key === 'relicense' && !hasLicenseField(license, 'allowDifferentLicense')) {
|
||||
return;
|
||||
}
|
||||
if ((config.key === 'commercial' || config.key === 'genServices') && !hasLicenseField(license, 'allowCommercialUse')) {
|
||||
return;
|
||||
}
|
||||
const allowed = config.allowedFn(license);
|
||||
const label = allowed ? config.labelAllowed : config.labelDenied;
|
||||
icons.push(createNewLicenseIconMarkup(config.icon, allowed, label));
|
||||
});
|
||||
|
||||
if (!icons.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const containerLabel = translate('modals.model.license.restrictionsLabel', {}, 'License permissions');
|
||||
const safeContainerLabel = escapeAttribute(containerLabel);
|
||||
return `<div class="license-permissions" aria-label="${safeContainerLabel}" role="group">
|
||||
${icons.join('\n ')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the model modal with the given model data
|
||||
* @param {Object} model - Model data object
|
||||
@@ -264,7 +353,10 @@ export async function showModelModal(model, modelType) {
|
||||
};
|
||||
const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || '');
|
||||
const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A');
|
||||
const licenseIcons = renderLicenseIcons(modelWithFullData);
|
||||
const useNewIcons = state.global.settings.use_new_license_icons !== false;
|
||||
const licenseIcons = useNewIcons
|
||||
? renderNewLicenseIcons(modelWithFullData)
|
||||
: renderLicenseIcons(modelWithFullData);
|
||||
const viewOnCivitaiAction = modelWithFullData.from_civitai ? `
|
||||
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}">
|
||||
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
|
||||
|
||||
@@ -327,10 +327,15 @@ export class DoctorManager {
|
||||
case 'open-settings':
|
||||
modalManager.showModal('settingsModal');
|
||||
window.setTimeout(() => {
|
||||
const input = document.getElementById('civitaiApiKey');
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// Open the API key editor directly
|
||||
if (typeof settingsManager.editApiKey === 'function') {
|
||||
settingsManager.editApiKey();
|
||||
} else {
|
||||
const input = document.getElementById('civitaiApiKey');
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
break;
|
||||
|
||||
@@ -321,29 +321,94 @@ class MoveManager {
|
||||
}
|
||||
|
||||
try {
|
||||
let movedFiles = []; // Array of { original_file_path, new_file_path }
|
||||
|
||||
if (this.bulkFilePaths) {
|
||||
// Bulk move mode
|
||||
await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
|
||||
|
||||
const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
|
||||
movedFiles = (results || [])
|
||||
.filter(r => r.success)
|
||||
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
||||
|
||||
// Deselect moving items
|
||||
this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
|
||||
} else {
|
||||
// Single move mode
|
||||
await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath);
|
||||
|
||||
const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath);
|
||||
if (result) {
|
||||
movedFiles.push({
|
||||
original_file_path: result.original_file_path || this.currentFilePath,
|
||||
new_file_path: result.new_file_path
|
||||
});
|
||||
}
|
||||
|
||||
// Deselect moving item
|
||||
bulkManager.deselectItem(this.currentFilePath);
|
||||
}
|
||||
|
||||
// Refresh UI by reloading the current page, same as drag-and-drop behavior
|
||||
// This ensures all metadata (like preview URLs) are correctly formatted by the backend
|
||||
if (sidebarManager.pageControls && typeof sidebarManager.pageControls.resetAndReload === 'function') {
|
||||
await sidebarManager.pageControls.resetAndReload(true);
|
||||
} else if (sidebarManager.lastPageControls && typeof sidebarManager.lastPageControls.resetAndReload === 'function') {
|
||||
await sidebarManager.lastPageControls.resetAndReload(true);
|
||||
// Update VirtualScroller in-place instead of full reload
|
||||
if (movedFiles.length > 0 && state.virtualScroller) {
|
||||
// Get current page state for folder filter check
|
||||
const pageState = getCurrentPageState();
|
||||
const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
|
||||
const isRecursive = pageState.searchOptions?.recursive ?? true;
|
||||
const isFolderFiltered = pageState.activeFolder !== null;
|
||||
|
||||
// Determine which items are still visible after the move
|
||||
const pathsToRemove = [];
|
||||
const pathsToUpdate = []; // { originalPath, newData }
|
||||
|
||||
for (const moved of movedFiles) {
|
||||
if (!moved.original_file_path) continue;
|
||||
|
||||
if (isFolderFiltered) {
|
||||
// Compute relative folder of the new path
|
||||
const newRelativeFolder = this._getRelativeFolder(moved.new_file_path);
|
||||
const normalizedNewFolder = newRelativeFolder.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
|
||||
// Check if the new location is still within the active folder
|
||||
let stillVisible;
|
||||
if (isRecursive) {
|
||||
stillVisible = normalizedActive === '' ||
|
||||
normalizedNewFolder === normalizedActive ||
|
||||
normalizedNewFolder.startsWith(normalizedActive + '/');
|
||||
} else {
|
||||
stillVisible = normalizedNewFolder === normalizedActive;
|
||||
}
|
||||
|
||||
if (stillVisible) {
|
||||
pathsToUpdate.push({
|
||||
originalPath: moved.original_file_path,
|
||||
newData: {
|
||||
file_path: moved.new_file_path,
|
||||
folder: newRelativeFolder
|
||||
}
|
||||
});
|
||||
} else {
|
||||
pathsToRemove.push(moved.original_file_path);
|
||||
}
|
||||
} else {
|
||||
// No folder filter active — items remain visible, just update path
|
||||
pathsToUpdate.push({
|
||||
originalPath: moved.original_file_path,
|
||||
newData: {
|
||||
file_path: moved.new_file_path,
|
||||
folder: this._getRelativeFolder(moved.new_file_path)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply updates to the VirtualScroller
|
||||
if (pathsToRemove.length > 0) {
|
||||
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
|
||||
}
|
||||
for (const update of pathsToUpdate) {
|
||||
state.virtualScroller.updateSingleItem(update.originalPath, update.newData);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh folder tree in sidebar
|
||||
// Refresh folder tree in sidebar (no model data reload)
|
||||
await sidebarManager.refresh();
|
||||
|
||||
modalManager.closeModal('moveModal');
|
||||
|
||||
@@ -344,9 +344,14 @@ export class SettingsManager {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
|
||||
this.isOpen = settingsModal.style.display === 'block';
|
||||
|
||||
// When modal is opened, update checkbox state from current settings
|
||||
if (this.isOpen) {
|
||||
this.loadSettingsToUI();
|
||||
} else {
|
||||
// Reset API key edit mode on close
|
||||
this.cancelEditApiKey(true);
|
||||
// Clear proxy password on close
|
||||
const proxyPasswordInput = document.getElementById('proxyPassword');
|
||||
if (proxyPasswordInput) proxyPasswordInput.value = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -805,12 +810,14 @@ export class SettingsManager {
|
||||
|
||||
// Set card blur amount slider
|
||||
const cardBlurAmountInput = document.getElementById('cardBlurAmount');
|
||||
const cardBlurValue = state.global.settings.card_blur_amount ?? 8;
|
||||
if (cardBlurAmountInput) {
|
||||
cardBlurAmountInput.value = state.global.settings.card_blur_amount ?? 8;
|
||||
cardBlurAmountInput.value = cardBlurValue;
|
||||
cardBlurAmountInput.style.setProperty('--range-fill', (cardBlurValue / 20 * 100) + '%');
|
||||
}
|
||||
const cardBlurAmountValue = document.getElementById('cardBlurAmountValue');
|
||||
if (cardBlurAmountValue) {
|
||||
cardBlurAmountValue.textContent = `${state.global.settings.card_blur_amount ?? 8}px`;
|
||||
cardBlurAmountValue.textContent = `${cardBlurValue}px`;
|
||||
}
|
||||
|
||||
const usePortableCheckbox = document.getElementById('usePortableSettings');
|
||||
@@ -818,6 +825,9 @@ export class SettingsManager {
|
||||
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
|
||||
}
|
||||
|
||||
// Update API key status display (do NOT pre-fill the input)
|
||||
this.updateApiKeyStatus();
|
||||
|
||||
const civitaiHostSelect = document.getElementById('civitaiHost');
|
||||
if (civitaiHostSelect) {
|
||||
civitaiHostSelect.value = state.global.settings.civitai_host || 'civitai.com';
|
||||
@@ -1001,6 +1011,12 @@ export class SettingsManager {
|
||||
|
||||
this.loadDownloadBackendSettings();
|
||||
this.loadProxySettings();
|
||||
|
||||
// Set license icon style
|
||||
const useNewLicenseIconsCheckbox = document.getElementById('useNewLicenseIcons');
|
||||
if (useNewLicenseIconsCheckbox) {
|
||||
useNewLicenseIconsCheckbox.checked = state.global.settings.use_new_license_icons !== false;
|
||||
}
|
||||
}
|
||||
|
||||
loadDownloadBackendSettings() {
|
||||
@@ -2070,6 +2086,9 @@ export class SettingsManager {
|
||||
displayEl.textContent = `${value}px`;
|
||||
}
|
||||
|
||||
const max = parseInt(element.max, 10) || 20;
|
||||
element.style.setProperty('--range-fill', (value / max * 100) + '%');
|
||||
|
||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||
} catch (error) {
|
||||
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
||||
@@ -2877,16 +2896,97 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
// ── CivitAI API Key management ──────────────────────────────
|
||||
|
||||
updateApiKeyStatus() {
|
||||
const hasKey = !!(state.global.settings.civitai_api_key_set ||
|
||||
state.global.settings.civitai_api_key);
|
||||
const statusEl = document.getElementById('civitaiApiKeyStatus');
|
||||
const statusText = document.getElementById('civitaiApiKeyStatusText');
|
||||
const actionBtn = document.getElementById('civitaiApiKeyActionBtn');
|
||||
if (!statusText || !actionBtn) return;
|
||||
|
||||
if (hasKey) {
|
||||
statusText.classList.remove('api-key-status--unconfigured');
|
||||
statusText.classList.add('api-key-status--configured');
|
||||
statusText.innerHTML = '<i class="fas fa-check-circle text-success"></i> '
|
||||
+ translate('settings.civitaiApiKeyConfigured', {}, 'Configured');
|
||||
actionBtn.textContent = translate('common.actions.change', {}, 'Change');
|
||||
} else {
|
||||
statusText.classList.remove('api-key-status--configured');
|
||||
statusText.classList.add('api-key-status--unconfigured');
|
||||
statusText.innerHTML = '<i class="fas fa-times-circle text-error"></i> '
|
||||
+ translate('settings.civitaiApiKeyNotConfigured', {}, 'Not configured');
|
||||
actionBtn.textContent = translate('settings.civitaiApiKeySet', {}, 'Set up');
|
||||
}
|
||||
}
|
||||
|
||||
editApiKey() {
|
||||
const statusEl = document.getElementById('civitaiApiKeyStatus');
|
||||
if (statusEl) statusEl.classList.add('is-hidden');
|
||||
const editContainer = document.getElementById('civitaiApiKeyEdit');
|
||||
if (editContainer) editContainer.classList.remove('is-hidden');
|
||||
// Focus the input
|
||||
const input = document.getElementById('civitaiApiKey');
|
||||
if (input) {
|
||||
input.value = ''; // Never pre-fill the secret
|
||||
setTimeout(() => input.focus(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
cancelEditApiKey(silent) {
|
||||
const editContainer = document.getElementById('civitaiApiKeyEdit');
|
||||
if (editContainer) editContainer.classList.add('is-hidden');
|
||||
const statusContainer = document.getElementById('civitaiApiKeyStatus');
|
||||
if (statusContainer) statusContainer.classList.remove('is-hidden');
|
||||
// Clear any typed value
|
||||
const input = document.getElementById('civitaiApiKey');
|
||||
if (input) input.value = '';
|
||||
if (!silent) {
|
||||
this.updateApiKeyStatus();
|
||||
}
|
||||
}
|
||||
|
||||
async saveApiKey() {
|
||||
const input = document.getElementById('civitaiApiKey');
|
||||
if (!input) return;
|
||||
|
||||
const value = input.value.trim();
|
||||
|
||||
try {
|
||||
await this.saveSetting('civitai_api_key', value);
|
||||
showToast('toast.settings.settingsUpdated',
|
||||
{ setting: 'CivitAI API Key' }, 'success');
|
||||
} catch (error) {
|
||||
showToast('toast.settings.settingSaveFailed',
|
||||
{ message: error.message }, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the in-memory flag so the UI reflects the change
|
||||
state.global.settings.civitai_api_key_set = !!value;
|
||||
this.cancelEditApiKey(true);
|
||||
this.updateApiKeyStatus();
|
||||
}
|
||||
|
||||
toggleInputVisibility(button) {
|
||||
const input = button.parentElement.querySelector('input');
|
||||
if (!input) return;
|
||||
const icon = button.querySelector('i');
|
||||
|
||||
if (input.type === 'password') {
|
||||
if (input.dataset.mask === 'css') {
|
||||
// CSS-masked input (CivitAI API key) — toggle class, not type
|
||||
input.classList.toggle('api-key-masked');
|
||||
if (icon) {
|
||||
icon.className = input.classList.contains('api-key-masked')
|
||||
? 'fas fa-eye'
|
||||
: 'fas fa-eye-slash';
|
||||
}
|
||||
} else if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.className = 'fas fa-eye-slash';
|
||||
if (icon) icon.className = 'fas fa-eye-slash';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.className = 'fas fa-eye';
|
||||
if (icon) icon.className = 'fas fa-eye';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2942,6 +3042,10 @@ export class SettingsManager {
|
||||
const showVersionOnCard = state.global.settings.show_version_on_card !== false;
|
||||
document.body.classList.toggle('hide-card-version', !showVersionOnCard);
|
||||
|
||||
// Apply license icon style
|
||||
const useNewLicenseIcons = state.global.settings.use_new_license_icons !== false;
|
||||
document.body.classList.toggle('use-new-license-icons', useNewLicenseIcons);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/co
|
||||
|
||||
const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
civitai_api_key: '',
|
||||
civitai_api_key_set: false,
|
||||
civitai_host: 'civitai.com',
|
||||
download_backend: 'python',
|
||||
aria2c_path: '',
|
||||
@@ -52,6 +53,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
backup_auto_enabled: true,
|
||||
backup_retention_count: 5,
|
||||
strip_lora_on_copy: false,
|
||||
use_new_license_icons: true,
|
||||
});
|
||||
|
||||
export function createDefaultSettings() {
|
||||
|
||||
@@ -240,6 +240,9 @@ export class StatisticsManager {
|
||||
|
||||
// Storage efficiency chart
|
||||
this.createStorageEfficiencyChart();
|
||||
|
||||
// Model types chart (Collection tab)
|
||||
this.createModelTypesChart();
|
||||
}
|
||||
|
||||
createCollectionPieChart() {
|
||||
@@ -554,6 +557,68 @@ export class StatisticsManager {
|
||||
});
|
||||
}
|
||||
|
||||
createModelTypesChart() {
|
||||
const ctx = document.getElementById('modelTypesChart');
|
||||
if (!ctx || !this.data.collection || !this.data.collection.model_types_distribution) return;
|
||||
|
||||
const distribution = this.data.collection.model_types_distribution;
|
||||
const typeDisplayNames = {
|
||||
lora: 'LoRA',
|
||||
locon: 'LyCORIS',
|
||||
dora: 'DoRA',
|
||||
checkpoint: 'Checkpoint',
|
||||
diffusion_model: 'Diffusion Model',
|
||||
embedding: 'Embeddings'
|
||||
};
|
||||
|
||||
const colorPalette = {
|
||||
lora: 'oklch(68% 0.28 256)',
|
||||
locon: 'oklch(68% 0.25 190)',
|
||||
dora: 'oklch(68% 0.25 330)',
|
||||
checkpoint: 'oklch(68% 0.28 45)',
|
||||
diffusion_model: 'oklch(68% 0.25 280)',
|
||||
embedding: 'oklch(68% 0.25 120)'
|
||||
};
|
||||
|
||||
const labels = Object.keys(distribution).map(k => typeDisplayNames[k] || k);
|
||||
const values = Object.values(distribution);
|
||||
const colors = Object.keys(distribution).map(k => colorPalette[k] || 'oklch(68% 0.15 0)');
|
||||
|
||||
const data = {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: values,
|
||||
backgroundColor: colors,
|
||||
borderColor: getComputedStyle(document.documentElement).getPropertyValue('--border-color'),
|
||||
borderWidth: 2
|
||||
}]
|
||||
};
|
||||
|
||||
this.charts.modelTypes = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const value = context.parsed;
|
||||
const pct = ((value / total) * 100).toFixed(1);
|
||||
return ` ${context.label}: ${value} (${pct}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async initializeLists() {
|
||||
const listTypes = [
|
||||
{ type: 'lora', containerId: 'topLorasList' },
|
||||
|
||||
@@ -931,6 +931,38 @@ export class VirtualScroller {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove multiple items by their file paths.
|
||||
* More efficient than calling removeItemByFilePath individually.
|
||||
* @param {string[]} filePaths - Array of file paths to remove
|
||||
* @returns {boolean} - True if any items were removed
|
||||
*/
|
||||
removeMultipleItemsByFilePath(filePaths) {
|
||||
if (!Array.isArray(filePaths) || filePaths.length === 0 || this.disabled || this.items.length === 0) return false;
|
||||
|
||||
// Build a set for fast lookup
|
||||
const pathsToRemove = new Set(filePaths);
|
||||
const originalLength = this.items.length;
|
||||
|
||||
// Filter out removed items; keep those not in the set
|
||||
this.items = this.items.filter(item => !pathsToRemove.has(item.file_path));
|
||||
|
||||
const removedCount = originalLength - this.items.length;
|
||||
if (removedCount === 0) return false;
|
||||
|
||||
this.totalItems = Math.max(0, this.totalItems - removedCount);
|
||||
|
||||
// Update the spacer height
|
||||
this.updateSpacerHeight();
|
||||
|
||||
// Re-render to fill gaps left by removed items
|
||||
this.clearRenderedItems();
|
||||
this.scheduleRender();
|
||||
|
||||
console.log(`Removed ${removedCount} items from virtual scroller data`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Add keyboard navigation methods
|
||||
handlePageUpDown(direction) {
|
||||
// Prevent duplicate animations by checking last trigger time
|
||||
|
||||
@@ -197,11 +197,22 @@ export function restoreFolderFilter() {
|
||||
}
|
||||
}
|
||||
|
||||
const CYCLE_ORDER = ['auto', 'light', 'dark'];
|
||||
const PRESET_NAMES = ['default', 'nord', 'midnight', 'monokai', 'dracula', 'solarized'];
|
||||
|
||||
export { CYCLE_ORDER, PRESET_NAMES };
|
||||
|
||||
export function initTheme() {
|
||||
const savedTheme = getStorageItem('theme') || 'auto';
|
||||
// Migrate deprecated presets
|
||||
let savedPreset = getStorageItem('theme_preset');
|
||||
if (savedPreset === 'gruvbox') {
|
||||
savedPreset = 'midnight';
|
||||
setStorageItem('theme_preset', 'midnight');
|
||||
}
|
||||
applyTheme(savedTheme);
|
||||
applyPreset(savedPreset || 'default');
|
||||
|
||||
// Update theme when system preference changes (for 'auto' mode)
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
const currentTheme = getStorageItem('theme') || 'auto';
|
||||
if (currentTheme === 'auto') {
|
||||
@@ -212,34 +223,44 @@ export function initTheme() {
|
||||
|
||||
export function toggleTheme() {
|
||||
const currentTheme = getStorageItem('theme') || 'auto';
|
||||
let newTheme;
|
||||
|
||||
if (currentTheme === 'light') {
|
||||
newTheme = 'dark';
|
||||
} else {
|
||||
newTheme = 'light';
|
||||
}
|
||||
const currentIndex = CYCLE_ORDER.indexOf(currentTheme);
|
||||
const nextIndex = (currentIndex + 1) % CYCLE_ORDER.length;
|
||||
const newTheme = CYCLE_ORDER[nextIndex];
|
||||
|
||||
setStorageItem('theme', newTheme);
|
||||
applyTheme(newTheme);
|
||||
|
||||
// Force a repaint to ensure theme changes are applied immediately
|
||||
document.body.style.display = 'none';
|
||||
document.body.offsetHeight; // Trigger a reflow
|
||||
document.body.offsetHeight;
|
||||
document.body.style.display = '';
|
||||
|
||||
return newTheme;
|
||||
}
|
||||
|
||||
// Add a new helper function to apply the theme
|
||||
export function cyclePreset() {
|
||||
const currentPreset = getStorageItem('theme_preset') || 'default';
|
||||
const currentIndex = PRESET_NAMES.indexOf(currentPreset);
|
||||
const nextIndex = (currentIndex + 1) % PRESET_NAMES.length;
|
||||
const newPreset = PRESET_NAMES[nextIndex];
|
||||
|
||||
setStorageItem('theme_preset', newPreset);
|
||||
applyPreset(newPreset);
|
||||
|
||||
return newPreset;
|
||||
}
|
||||
|
||||
export function setPreset(name) {
|
||||
if (!PRESET_NAMES.includes(name)) return;
|
||||
setStorageItem('theme_preset', name);
|
||||
applyPreset(name);
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const htmlElement = document.documentElement;
|
||||
|
||||
// Remove any existing theme attributes
|
||||
htmlElement.removeAttribute('data-theme');
|
||||
|
||||
// Apply the appropriate theme
|
||||
if (theme === 'dark' || (theme === 'auto' && prefersDark)) {
|
||||
htmlElement.setAttribute('data-theme', 'dark');
|
||||
document.body.dataset.theme = 'dark';
|
||||
@@ -248,19 +269,18 @@ function applyTheme(theme) {
|
||||
document.body.dataset.theme = 'light';
|
||||
}
|
||||
|
||||
// Update the theme-toggle icon state
|
||||
updateThemeToggleIcons(theme);
|
||||
}
|
||||
|
||||
// New function to update theme toggle icons
|
||||
function applyPreset(preset) {
|
||||
document.documentElement.setAttribute('data-theme-preset', preset);
|
||||
}
|
||||
|
||||
function updateThemeToggleIcons(theme) {
|
||||
const themeToggle = document.querySelector('.theme-toggle');
|
||||
if (!themeToggle) return;
|
||||
|
||||
// Remove any existing active classes
|
||||
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
|
||||
|
||||
// Add the appropriate class based on current theme
|
||||
themeToggle.classList.add(`theme-${theme}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,16 +46,20 @@
|
||||
</script>
|
||||
<script>
|
||||
(function() {
|
||||
// Apply theme immediately based on stored preference
|
||||
const STORAGE_PREFIX = 'lora_manager_';
|
||||
const savedTheme = localStorage.getItem(STORAGE_PREFIX + 'theme') || 'auto';
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var STORAGE_PREFIX = 'lora_manager_';
|
||||
var savedTheme = localStorage.getItem(STORAGE_PREFIX + 'theme') || 'auto';
|
||||
var savedPreset = localStorage.getItem(STORAGE_PREFIX + 'theme_preset') || 'default';
|
||||
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
if (savedTheme === 'dark' || (savedTheme === 'auto' && prefersDark)) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
}
|
||||
|
||||
if (savedPreset && savedPreset !== 'default') {
|
||||
document.documentElement.setAttribute('data-theme-preset', savedPreset);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% block head_scripts %}{% endblock %}
|
||||
|
||||
@@ -100,30 +100,6 @@
|
||||
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="keyboard-nav-hint tooltip">
|
||||
<i class="fas fa-keyboard"></i>
|
||||
<span class="tooltiptext">
|
||||
<span>{{ t('keyboard.navigation') }}</span>
|
||||
<table class="keyboard-shortcuts">
|
||||
<tr>
|
||||
<td><span class="key">Page Up</span></td>
|
||||
<td>{{ t('keyboard.shortcuts.pageUp') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="key">Page Down</span></td>
|
||||
<td>{{ t('keyboard.shortcuts.pageDown') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="key">Home</span></td>
|
||||
<td>{{ t('keyboard.shortcuts.home') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="key">End</span></td>
|
||||
<td>{{ t('keyboard.shortcuts.end') }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,6 +120,56 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="theme-popover" id="themePopover" role="dialog" aria-label="{{ t('header.theme.toggle') }}">
|
||||
<div class="theme-popover-section">
|
||||
<div class="theme-popover-label">{{ t('header.theme.mode') }}</div>
|
||||
<div class="theme-popover-modes">
|
||||
<button class="theme-mode-btn" data-mode="light" title="{{ t('header.theme.light') }}">
|
||||
<i class="fas fa-sun"></i>
|
||||
<span>{{ t('header.theme.light') }}</span>
|
||||
</button>
|
||||
<button class="theme-mode-btn" data-mode="dark" title="{{ t('header.theme.dark') }}">
|
||||
<i class="fas fa-moon"></i>
|
||||
<span>{{ t('header.theme.dark') }}</span>
|
||||
</button>
|
||||
<button class="theme-mode-btn" data-mode="auto" title="{{ t('header.theme.auto') }}">
|
||||
<i class="fas fa-adjust"></i>
|
||||
<span>{{ t('header.theme.auto') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-popover-divider"></div>
|
||||
<div class="theme-popover-section">
|
||||
<div class="theme-popover-label">{{ t('header.theme.presets') }}</div>
|
||||
<div class="theme-popover-presets">
|
||||
<button class="theme-preset-btn" data-preset="default" title="{{ t('header.theme.default') }}">
|
||||
<span class="preset-swatch preset-swatch-default"></span>
|
||||
<span>{{ t('header.theme.default') }}</span>
|
||||
</button>
|
||||
<button class="theme-preset-btn" data-preset="nord" title="{{ t('header.theme.nord') }}">
|
||||
<span class="preset-swatch preset-swatch-nord"></span>
|
||||
<span>{{ t('header.theme.nord') }}</span>
|
||||
</button>
|
||||
<button class="theme-preset-btn" data-preset="midnight" title="{{ t('header.theme.midnight') }}">
|
||||
<span class="preset-swatch preset-swatch-midnight"></span>
|
||||
<span>{{ t('header.theme.midnight') }}</span>
|
||||
</button>
|
||||
<button class="theme-preset-btn" data-preset="monokai" title="{{ t('header.theme.monokai') }}">
|
||||
<span class="preset-swatch preset-swatch-monokai"></span>
|
||||
<span>{{ t('header.theme.monokai') }}</span>
|
||||
</button>
|
||||
<button class="theme-preset-btn" data-preset="dracula" title="{{ t('header.theme.dracula') }}">
|
||||
<span class="preset-swatch preset-swatch-dracula"></span>
|
||||
<span>{{ t('header.theme.dracula') }}</span>
|
||||
</button>
|
||||
<button class="theme-preset-btn" data-preset="solarized" title="{{ t('header.theme.solarized') }}">
|
||||
<span class="preset-swatch preset-swatch-solarized"></span>
|
||||
<span>{{ t('header.theme.solarized') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add search options panel with context-aware options -->
|
||||
<div id="searchOptionsPanel" class="search-options-panel hidden">
|
||||
<div class="options-header">
|
||||
|
||||
@@ -95,22 +95,36 @@
|
||||
<div class="setting-item api-key-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="civitaiApiKey">{{ t('settings.civitaiApiKey') }}</label>
|
||||
<label>{{ t('settings.civitaiApiKey') }}</label>
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.civitaiApiKeyHelp') }}"></i>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<div class="api-key-input">
|
||||
<input type="password"
|
||||
id="civitaiApiKey"
|
||||
placeholder="{{ t('settings.civitaiApiKeyPlaceholder') }}"
|
||||
value="{{ settings.get('civitai_api_key', '') }}"
|
||||
autocomplete="new-password"
|
||||
onblur="settingsManager.saveInputSetting('civitaiApiKey', 'civitai_api_key')"
|
||||
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||
<button class="toggle-visibility">
|
||||
<i class="fas fa-eye"></i>
|
||||
<!-- Status display (shown when not editing) -->
|
||||
<div id="civitaiApiKeyStatus" class="api-key-status">
|
||||
<span id="civitaiApiKeyStatusText" class="api-key-status-text api-key-status--unconfigured">
|
||||
<i class="fas fa-times-circle text-error"></i>
|
||||
{{ t('settings.civitaiApiKeyNotConfigured') }}
|
||||
</span>
|
||||
<button type="button" class="secondary-btn" id="civitaiApiKeyActionBtn" onclick="settingsManager.editApiKey()">
|
||||
{{ t('settings.civitaiApiKeySet') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Inline edit view (shown when editing) -->
|
||||
<div id="civitaiApiKeyEdit" class="api-key-edit is-hidden">
|
||||
<div class="api-key-input">
|
||||
<input type="text"
|
||||
id="civitaiApiKey"
|
||||
class="api-key-masked"
|
||||
placeholder="{{ t('settings.civitaiApiKeyPlaceholder') }}"
|
||||
autocomplete="off"
|
||||
data-mask="css" />
|
||||
<button type="button" class="toggle-visibility">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="primary-btn" onclick="settingsManager.saveApiKey()">{{ t('common.actions.save') }}</button>
|
||||
<button type="button" class="secondary-btn" onclick="settingsManager.cancelEditApiKey()">{{ t('common.actions.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -549,7 +563,7 @@
|
||||
</div>
|
||||
<div class="setting-control range-control">
|
||||
<input type="range" id="cardBlurAmount" min="0" max="20" value="8" step="1"
|
||||
oninput="document.getElementById('cardBlurAmountValue').textContent = this.value + 'px'"
|
||||
oninput="var pct = (this.value / 20) * 100; this.style.setProperty('--range-fill', pct + '%'); document.getElementById('cardBlurAmountValue').textContent = this.value + 'px'"
|
||||
onchange="settingsManager.saveRangeSetting('cardBlurAmount', 'cardBlurAmountValue', 'card_blur_amount')">
|
||||
<span id="cardBlurAmountValue" class="range-value">8px</span>
|
||||
</div>
|
||||
@@ -592,6 +606,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- License Icons -->
|
||||
<div class="settings-subsection">
|
||||
<div class="settings-subsection-header">
|
||||
<h4>{{ t('settings.sections.licenseIcons') }}</h4>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="useNewLicenseIcons">
|
||||
{{ t('settings.licenseIcons.useNewStyle') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.licenseIcons.useNewStyleHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="useNewLicenseIcons"
|
||||
onchange="settingsManager.saveToggleSetting('useNewLicenseIcons', 'use_new_license_icons')">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Miscellaneous -->
|
||||
<div class="settings-subsection">
|
||||
<div class="settings-subsection-header">
|
||||
|
||||
@@ -137,30 +137,6 @@
|
||||
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="keyboard-nav-hint tooltip">
|
||||
<i class="fas fa-keyboard"></i>
|
||||
<span class="tooltiptext">
|
||||
<span>{{ t('keyboard.navigation') }}</span>
|
||||
<table class="keyboard-shortcuts">
|
||||
<tr>
|
||||
<td><span class="key">Page Up</span></td>
|
||||
<td>{{ t('keyboard.shortcuts.pageUp') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="key">Page Down</span></td>
|
||||
<td>{{ t('keyboard.shortcuts.pageDown') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="key">Home</span></td>
|
||||
<td>{{ t('keyboard.shortcuts.home') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="key">End</span></td>
|
||||
<td>{{ t('keyboard.shortcuts.end') }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -101,14 +101,19 @@ vi.mock(API_FACTORY, () => ({
|
||||
|
||||
describe('Model modal license rendering', () => {
|
||||
let getModelApiClient;
|
||||
let state;
|
||||
|
||||
beforeEach(async () => {
|
||||
document.body.innerHTML = '';
|
||||
({ getModelApiClient } = await import(API_FACTORY));
|
||||
getModelApiClient.mockReset();
|
||||
// Import state and force classic icons for this test
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
state = stateModule.state;
|
||||
state.global.settings.use_new_license_icons = false;
|
||||
});
|
||||
|
||||
it('handles aggregated commercial strings without extra restrictions', async () => {
|
||||
it('handles aggregated commercial strings without extra restrictions (classic style)', async () => {
|
||||
const fetchModelMetadata = vi.fn().mockResolvedValue(null);
|
||||
getModelApiClient.mockReturnValue({
|
||||
fetchModelMetadata,
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
'messages': list([
|
||||
]),
|
||||
'settings': dict({
|
||||
'civitai_api_key': 'test-key',
|
||||
'civitai_api_key_set': True,
|
||||
'language': 'en',
|
||||
'theme': 'dark',
|
||||
}),
|
||||
|
||||
@@ -134,8 +134,10 @@ async def test_get_settings_excludes_no_sync_keys():
|
||||
|
||||
assert payload["success"] is True
|
||||
# Regular settings should be synced
|
||||
assert payload["settings"]["civitai_api_key"] == "abc"
|
||||
assert payload["settings"]["regular_setting"] == "value"
|
||||
# civitai_api_key is in _NO_SYNC_KEYS; only the boolean flag is returned
|
||||
assert payload["settings"].get("civitai_api_key") is None
|
||||
assert payload["settings"]["civitai_api_key_set"] is True
|
||||
# _NO_SYNC_KEYS should not be synced
|
||||
assert "hash_chunk_size_mb" not in payload["settings"]
|
||||
assert "folder_paths" not in payload["settings"]
|
||||
|
||||
@@ -73,6 +73,40 @@
|
||||
mask: var(--license-icon-image) center/contain no-repeat;
|
||||
}
|
||||
|
||||
/* Set 2 — new style license overlay */
|
||||
.lm-tooltip__license-overlay-new {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(10, 10, 14, 0.78);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
max-width: calc(100% - 16px);
|
||||
}
|
||||
|
||||
.lm-tooltip__license-icon-new {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-block;
|
||||
border-radius: 3px;
|
||||
-webkit-mask: var(--license-icon-image) center/contain no-repeat;
|
||||
mask: var(--license-icon-image) center/contain no-repeat;
|
||||
}
|
||||
|
||||
.lm-tooltip__license-icon-new.allowed {
|
||||
background-color: #40c057;
|
||||
}
|
||||
|
||||
.lm-tooltip__license-icon-new.denied {
|
||||
background-color: #fa5252;
|
||||
}
|
||||
|
||||
.lm-loras-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -12,6 +12,8 @@ const LICENSE_FLAG_BITS = {
|
||||
allowRelicense: 1 << 6,
|
||||
};
|
||||
|
||||
// ── Set 1 (classic) icon definitions ──
|
||||
|
||||
const LICENSE_ICON_COPY = {
|
||||
credit: "Creator credit required",
|
||||
image: "No selling generated content",
|
||||
@@ -29,6 +31,51 @@ const COMMERCIAL_ICON_CONFIG = [
|
||||
{ bit: LICENSE_FLAG_BITS.allowSellingModels, icon: "shopping-cart-off.svg", label: LICENSE_ICON_COPY.sell },
|
||||
];
|
||||
|
||||
// ── Set 2 (new CivitAI-style) icon definitions ──
|
||||
|
||||
const LNI = LICENSE_ICON_PATH; // alias for brevity
|
||||
|
||||
const NEW_LICENSE_ICON_COPY = {
|
||||
commercial: { allowed: "Commercial use allowed", denied: "No commercial use" },
|
||||
genServices: { allowed: "Generation services allowed", denied: "No generation services" },
|
||||
credit: { allowed: "No credit required", denied: "Creator credit required" },
|
||||
derivatives: { allowed: "Merges allowed", denied: "No merges allowed" },
|
||||
relicense: { allowed: "Different permissions allowed on merges", denied: "Same permissions required on merges" },
|
||||
};
|
||||
|
||||
const NEW_ICON_CONFIG = [
|
||||
{
|
||||
bitCombo: [LICENSE_FLAG_BITS.allowOnImages, LICENSE_FLAG_BITS.allowSellingModels],
|
||||
icon: "currency-dollar.svg",
|
||||
labelKey: "commercial",
|
||||
allowedFn: (flags) => (flags & LICENSE_FLAG_BITS.allowOnImages) !== 0 || (flags & LICENSE_FLAG_BITS.allowSellingModels) !== 0,
|
||||
},
|
||||
{
|
||||
bitCombo: [LICENSE_FLAG_BITS.allowOnCivitai, LICENSE_FLAG_BITS.allowRental],
|
||||
icon: "brush.svg",
|
||||
labelKey: "genServices",
|
||||
allowedFn: (flags) => (flags & LICENSE_FLAG_BITS.allowOnCivitai) !== 0 || (flags & LICENSE_FLAG_BITS.allowRental) !== 0,
|
||||
},
|
||||
{
|
||||
bitCombo: [LICENSE_FLAG_BITS.allowNoCredit],
|
||||
icon: "user.svg",
|
||||
labelKey: "credit",
|
||||
allowedFn: (flags) => (flags & LICENSE_FLAG_BITS.allowNoCredit) !== 0,
|
||||
},
|
||||
{
|
||||
bitCombo: [LICENSE_FLAG_BITS.allowDerivatives],
|
||||
icon: "git-merge.svg",
|
||||
labelKey: "derivatives",
|
||||
allowedFn: (flags) => (flags & LICENSE_FLAG_BITS.allowDerivatives) !== 0,
|
||||
},
|
||||
{
|
||||
bitCombo: [LICENSE_FLAG_BITS.allowRelicense],
|
||||
icon: "license.svg",
|
||||
labelKey: "relicense",
|
||||
allowedFn: (flags) => (flags & LICENSE_FLAG_BITS.allowRelicense) !== 0,
|
||||
},
|
||||
];
|
||||
|
||||
function parseLicenseFlags(value) {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
@@ -78,6 +125,81 @@ function createLicenseIconElement({ icon, label }) {
|
||||
return element;
|
||||
}
|
||||
|
||||
// ── Set 2 (new style) helpers ──
|
||||
|
||||
function buildNewLicenseIconData(licenseFlags) {
|
||||
if (licenseFlags == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return NEW_ICON_CONFIG.map((config) => {
|
||||
const allowed = config.allowedFn(licenseFlags);
|
||||
const label = allowed
|
||||
? NEW_LICENSE_ICON_COPY[config.labelKey].allowed
|
||||
: NEW_LICENSE_ICON_COPY[config.labelKey].denied;
|
||||
return {
|
||||
icon: config.icon,
|
||||
label,
|
||||
allowed,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createNewLicenseIconElement({ icon, label, allowed }) {
|
||||
const element = document.createElement("span");
|
||||
element.className = `lm-tooltip__license-icon-new ${allowed ? "allowed" : "denied"}`;
|
||||
element.setAttribute("role", "img");
|
||||
element.setAttribute("aria-label", label);
|
||||
element.title = label;
|
||||
element.style.setProperty("--license-icon-image", `url('${LICENSE_ICON_PATH}${icon}')`);
|
||||
return element;
|
||||
}
|
||||
|
||||
const LICENSE_ICON_STORAGE_KEY = "lm_license_icon_new_style";
|
||||
|
||||
// Module-level cache: null = not yet initialized
|
||||
let _useNewIconsCached = null;
|
||||
|
||||
// Fetch the setting from the LoRA Manager backend API via the proper
|
||||
// ComfyUI api helper (handles base URL, credentials, etc.).
|
||||
// Stores the result in both the in-memory cache and localStorage so the
|
||||
// value survives page reloads even before the API responds.
|
||||
async function _fetchLicenseIconSetting() {
|
||||
try {
|
||||
const response = await api.fetchApi("/lm/settings");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const value = data.use_new_license_icons !== false;
|
||||
_useNewIconsCached = value;
|
||||
try { localStorage.setItem(LICENSE_ICON_STORAGE_KEY, String(value)); } catch (_) {}
|
||||
}
|
||||
} catch (_) {
|
||||
// API not available; cached/localStorage fallback stays in place
|
||||
}
|
||||
}
|
||||
|
||||
function getUseNewLicenseIcons() {
|
||||
// 1) In-memory cache hit
|
||||
if (_useNewIconsCached !== null) {
|
||||
return _useNewIconsCached;
|
||||
}
|
||||
|
||||
// 2) localStorage — survives page reloads
|
||||
try {
|
||||
const stored = localStorage.getItem(LICENSE_ICON_STORAGE_KEY);
|
||||
if (stored !== null) {
|
||||
_useNewIconsCached = stored === "true";
|
||||
// Refresh from API in background for next time
|
||||
_fetchLicenseIconSetting();
|
||||
return _useNewIconsCached;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// 3) First-ever run: kick off API fetch, default to new style
|
||||
_fetchLicenseIconSetting();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight preview tooltip that can display images or videos for different model types.
|
||||
*/
|
||||
@@ -101,6 +223,10 @@ export class PreviewTooltip {
|
||||
|
||||
ensureLmStyles();
|
||||
|
||||
// Pre-fetch license icon style from LM backend so the tooltip
|
||||
// respects the standalone settings toggle as early as possible.
|
||||
_fetchLicenseIconSetting();
|
||||
|
||||
this.element = document.createElement("div");
|
||||
this.element.className = "lm-tooltip";
|
||||
document.body.appendChild(this.element);
|
||||
@@ -135,6 +261,7 @@ export class PreviewTooltip {
|
||||
previewUrl: data.preview_url,
|
||||
displayName: data.display_name ?? modelName,
|
||||
licenseFlags: parseLicenseFlags(data.license_flags),
|
||||
useNewLicenseIcons: data.use_new_license_icons,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,7 +277,7 @@ export class PreviewTooltip {
|
||||
};
|
||||
}
|
||||
|
||||
const { previewUrl, displayName, licenseFlags } = raw;
|
||||
const { previewUrl, displayName, licenseFlags, useNewLicenseIcons } = raw;
|
||||
if (!previewUrl) {
|
||||
throw new Error("No preview URL available");
|
||||
}
|
||||
@@ -161,6 +288,7 @@ export class PreviewTooltip {
|
||||
? displayName
|
||||
: this.displayNameFormatter(modelName),
|
||||
licenseFlags: parseLicenseFlags(licenseFlags),
|
||||
useNewLicenseIcons,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -182,7 +310,7 @@ export class PreviewTooltip {
|
||||
}
|
||||
|
||||
this.currentModelName = modelName;
|
||||
const { previewUrl, displayName, licenseFlags } = await this.resolvePreviewData(
|
||||
const { previewUrl, displayName, licenseFlags, useNewLicenseIcons } = await this.resolvePreviewData(
|
||||
modelName
|
||||
);
|
||||
|
||||
@@ -211,7 +339,7 @@ export class PreviewTooltip {
|
||||
nameLabel.className = "lm-tooltip__label";
|
||||
|
||||
mediaContainer.appendChild(mediaElement);
|
||||
this.renderLicenseOverlay(mediaContainer, licenseFlags);
|
||||
this.renderLicenseOverlay(mediaContainer, licenseFlags, useNewLicenseIcons);
|
||||
mediaContainer.appendChild(nameLabel);
|
||||
this.element.appendChild(mediaContainer);
|
||||
|
||||
@@ -293,16 +421,25 @@ export class PreviewTooltip {
|
||||
}
|
||||
}
|
||||
|
||||
renderLicenseOverlay(container, licenseFlags) {
|
||||
const icons = buildLicenseIconData(licenseFlags);
|
||||
renderLicenseOverlay(container, licenseFlags, useNewLicenseIcons) {
|
||||
const useNew = useNewLicenseIcons !== undefined ? useNewLicenseIcons : getUseNewLicenseIcons();
|
||||
const icons = useNew
|
||||
? buildNewLicenseIconData(licenseFlags)
|
||||
: buildLicenseIconData(licenseFlags);
|
||||
if (!icons.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "lm-tooltip__license-overlay";
|
||||
overlay.className = useNew
|
||||
? "lm-tooltip__license-overlay-new"
|
||||
: "lm-tooltip__license-overlay";
|
||||
icons.forEach((descriptor) => {
|
||||
overlay.appendChild(createLicenseIconElement(descriptor));
|
||||
overlay.appendChild(
|
||||
useNew
|
||||
? createNewLicenseIconElement(descriptor)
|
||||
: createLicenseIconElement(descriptor)
|
||||
);
|
||||
});
|
||||
container.appendChild(overlay);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user