Compare commits

..

19 Commits

Author SHA1 Message Date
Will Miao faf64f8986 fix(css): migrate duplicates component to canonical color tokens
Replace undefined --lora-accent-l/c/h and --lora-warning-l/c/h with
canonical --color-accent-l/c/h and --color-warning-l/c/h from the
design token system. Fix 5 border-color declarations missing oklch()
wrapper, fix var() space syntax error in .group-toggle-btn:hover,
and replace hardcoded green with --color-success token.
2026-06-18 22:41:46 +08:00
Will Miao a617487a43 fix(ui): lift theme popover out of header stacking context to appear above modals 2026-06-18 22:19:36 +08:00
Will Miao 3012a7aef3 fix(settings): prevent Firefox save-password prompt from API key input
- Remove server-side value='...' from password field in settings modal template
  so the API key is never baked into the DOM at page load time
- Populate the input dynamically via loadSettingsToUI() when modal opens
- Clear both API key and proxy password fields on modal close to prevent
  Firefox from detecting pre-filled password fields on page navigation
2026-06-18 21:57:03 +08:00
Will Miao 499e19de34 fix(modals): tone down batch summary modal styling - remove icons, flatten gradients, lock to design tokens
- Metadata Fetch Summary: remove per-card icons, demote total/duration cards
  to neutral border, drop title icon, fix table header border width
- Batch Import Summary: replace 3em centered hero with inline left-aligned
  layout, flatten progress bar gradient, simplify circular badges to plain
  colored icons, unify border widths to 4px and token namespace to --color-
- Lock all off-scale em typography to --text-{xs,lg} design tokens
2026-06-18 21:56:58 +08:00
Will Miao 9161762ca9 fix(sidebar): align hidden indicator height (48px) and icon size with sidebar header 2026-06-18 21:14:35 +08:00
Will Miao 9bbd26efe6 feat(license-icons): add second set of license icons matching current CivitAI design
- Add 5 new Tabler SVG icons (currency-dollar, brush, user, git-merge, license)
- Implement Set 2 rendering in ModelModal.js (standalone UI) with green/red
  permission indicators and preview_tooltip.js (ComfyUI widget)
- Add use_new_license_icons setting (default: true) with toggle in settings UI
- ComfyUI tooltip reads setting directly from preview-url API response to
  eliminate race conditions and respect standalone settings changes
- Remove the now-unused separate ComfyUI setting loramanager.license_icon_style
- Add CSS for both standalone (lora-modal.css) and widget (lm_styles.css)
- i18n: translate licenseIcons keys into all 10 supported languages
- Fix test to use classic style explicitly for continued coverage
2026-06-18 21:07:44 +08:00
Will Miao 258b2622d5 fix(sidebar): align restore indicator with sidebar header and add first-use breathing animation (#990) 2026-06-18 19:22:38 +08:00
Will Miao 80ec9085dd fix(theme): replace Gruvbox with Midnight, fix accent/info hue collisions and hardcoded colors
- Replace Gruvbox preset with Midnight (deep blue-purple, violet accent)
- Fix accent/info hue collisions in Nord, Monokai, Dracula, Solarized
- Fix Solarized error/warning collision (error-h 25->5) and WCAG contrast
- Make --color-skip-refresh-* follow --color-warning-h dynamically
- Replace hardcoded rgba(24,144,255) in onboarding.css with --color-accent
- Replace hardcoded #00B87A in import modals with --color-success
2026-06-18 18:57:53 +08:00
Will Miao c5c7373e10 feat(theme): add 5 preset color themes (Nord/Gruvbox/Monokai/Dracula/Solarized) with popover selector
Implements Approach C (dual-attribute: data-theme + data-theme-preset),
keeping all 106 existing [data-theme="dark"] overrides unchanged.

- Colors: 5 professionally designed oklch palettes in tokens/colors.css
- UI: popover theme selector with mode (Light/Dark/Auto) + preset grid
- JS: cycleTheme(), setPreset(), localStorage persistence
- Locale: 12 new translation keys across 10 languages
- Polish: solid accent swatches matching flat token-driven aesthetic
2026-06-18 09:53:40 +08:00
Will Miao b7721866e5 fix(stats): implement Model Types chart in Collection tab with correct type distribution 2026-06-18 06:48:46 +08:00
Will Miao 8314b9bedb feat(downloads): add /downloads/queue/status endpoint and integrate queue lifecycle
- New GET /api/lm/downloads/queue/status handler for non-terminal status
  transitions (queued -> downloading, downloading -> paused, etc.)
- Queue lifecycle auto-integration in DownloadManager._download_with_semaphore:
  downloading -> SQLite update_status('downloading') on semaphore acquire
  completed -> complete_download('completed') on success
  canceled -> complete_download('canceled') on CancelledError
  failed -> complete_download('failed') on Exception
- All queue operations wrapped in try/except to never break the download flow
2026-06-17 23:04:30 +08:00
Will Miao 75298a402f chore(release): bump version to v1.1.3 2026-06-17 17:52:56 +08:00
Will Miao 92b5efd414 fix: guard posix_fadvise on non-Linux platforms to prevent AttributeError on Windows (#988) 2026-06-17 17:22:10 +08:00
Will Miao 33ee392b7b feat(settings): redesign Card Overlay Blur range slider to match settings UI style 2026-06-17 15:24:14 +08:00
Will Miao 5237f8b7dc chore: remove keyboard navigation UI elements and related code
- Delete static/css/components/keyboard-nav.css entirely
- Remove @import of keyboard-nav.css from style.css
- Remove keyboard-nav-hint divs from controls.html and recipes.html
- Clean up all keyboard.* translation keys from 10 locale files

The actual keyboard scrolling handlers (PageUp/PageDown in infiniteScroll.js
and VirtualScroller.js) are kept as they provide core scroll functionality.
2026-06-17 15:07:34 +08:00
Will Miao 5107313fd1 revert: restore &logo=github parameter to release-date badge
This reverts commit 95bbc669efb1aa0c23b94be6f0a5e7a188f1c019.

The real issue was shields.io GitHub API token pool exhaustion (intermittent),
not the &logo=github parameter. All 3 badges (Discord, Release, Release Date)
were affected at various times due to the same root cause: shields.io
temporarily unable to query GitHub API.
2026-06-17 11:24:40 +08:00
Will Miao 95bbc66919 fix: remove broken logo parameter from release-date badge URL 2026-06-17 11:21:26 +08:00
Will Miao e268e59419 chore: stop tracking .docs/ and add to .gitignore
.docs/ is now excluded from git tracking so working/research notes
can live there without being committed.
2026-06-17 11:20:19 +08:00
willmiao 547e1f9498 docs: auto-update supporters list in README 2026-06-17 01:57:52 +00:00
53 changed files with 1710 additions and 651 deletions
-153
View File
@@ -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
View File
@@ -28,3 +28,6 @@ vue-widgets/dist/
# Hypothesis test cache # Hypothesis test cache
.hypothesis/ .hypothesis/
# Working/research notes (not committed)
.docs/
+2 -2
View File
File diff suppressed because one or more lines are too long
+17 -10
View File
@@ -251,7 +251,18 @@
"toggle": "Theme wechseln", "toggle": "Theme wechseln",
"switchToLight": "Zu hellem Theme wechseln", "switchToLight": "Zu hellem Theme wechseln",
"switchToDark": "Zu dunklem 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": { "actions": {
"checkUpdates": "Updates prüfen", "checkUpdates": "Updates prüfen",
@@ -303,6 +314,7 @@
"downloads": "Downloads", "downloads": "Downloads",
"videoSettings": "Video-Einstellungen", "videoSettings": "Video-Einstellungen",
"layoutSettings": "Layout-Einstellungen", "layoutSettings": "Layout-Einstellungen",
"licenseIcons": "Lizenzsymbole",
"misc": "Verschiedenes", "misc": "Verschiedenes",
"backup": "Backups", "backup": "Backups",
"folderSettings": "Standard-Roots", "folderSettings": "Standard-Roots",
@@ -583,6 +595,10 @@
"label": "Früher Zugriff Updates ausblenden", "label": "Früher Zugriff Updates ausblenden",
"help": "Nur Early-Access-Updates" "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": { "misc": {
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen", "includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen", "includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen",
@@ -1424,15 +1440,6 @@
"duplicate": "Dieser Tag existiert bereits" "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": { "initialization": {
"title": "Initialisierung", "title": "Initialisierung",
"message": "Ihr Arbeitsbereich wird vorbereitet...", "message": "Ihr Arbeitsbereich wird vorbereitet...",
+17 -10
View File
@@ -251,7 +251,18 @@
"toggle": "Toggle theme", "toggle": "Toggle theme",
"switchToLight": "Switch to light theme", "switchToLight": "Switch to light theme",
"switchToDark": "Switch to dark 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": { "actions": {
"checkUpdates": "Check Updates", "checkUpdates": "Check Updates",
@@ -303,6 +314,7 @@
"downloads": "Downloads", "downloads": "Downloads",
"videoSettings": "Video Settings", "videoSettings": "Video Settings",
"layoutSettings": "Layout Settings", "layoutSettings": "Layout Settings",
"licenseIcons": "License Icons",
"misc": "Miscellaneous", "misc": "Miscellaneous",
"backup": "Backups", "backup": "Backups",
"folderSettings": "Default Roots", "folderSettings": "Default Roots",
@@ -583,6 +595,10 @@
"label": "Hide Early Access Updates", "label": "Hide Early Access Updates",
"help": "When enabled, models with only early access updates will not show 'Update available' badge" "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": { "misc": {
"includeTriggerWords": "Include Trigger Words in LoRA Syntax", "includeTriggerWords": "Include Trigger Words in LoRA Syntax",
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard", "includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard",
@@ -1424,15 +1440,6 @@
"duplicate": "This tag already exists" "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": { "initialization": {
"title": "Initializing", "title": "Initializing",
"message": "Preparing your workspace...", "message": "Preparing your workspace...",
+17 -10
View File
@@ -251,7 +251,18 @@
"toggle": "Cambiar tema", "toggle": "Cambiar tema",
"switchToLight": "Cambiar a tema claro", "switchToLight": "Cambiar a tema claro",
"switchToDark": "Cambiar a tema oscuro", "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": { "actions": {
"checkUpdates": "Comprobar actualizaciones", "checkUpdates": "Comprobar actualizaciones",
@@ -303,6 +314,7 @@
"downloads": "Descargas", "downloads": "Descargas",
"videoSettings": "Configuración de video", "videoSettings": "Configuración de video",
"layoutSettings": "Configuración de diseño", "layoutSettings": "Configuración de diseño",
"licenseIcons": "Iconos de licencia",
"misc": "Varios", "misc": "Varios",
"backup": "Copias de seguridad", "backup": "Copias de seguridad",
"folderSettings": "Raíces predeterminadas", "folderSettings": "Raíces predeterminadas",
@@ -583,6 +595,10 @@
"label": "Ocultar actualizaciones de acceso temprano", "label": "Ocultar actualizaciones de acceso temprano",
"help": "Solo 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": { "misc": {
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA", "includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles", "includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles",
@@ -1424,15 +1440,6 @@
"duplicate": "Esta etiqueta ya existe" "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": { "initialization": {
"title": "Inicializando", "title": "Inicializando",
"message": "Preparando tu espacio de trabajo...", "message": "Preparando tu espacio de trabajo...",
+17 -10
View File
@@ -251,7 +251,18 @@
"toggle": "Basculer le thème", "toggle": "Basculer le thème",
"switchToLight": "Passer au thème clair", "switchToLight": "Passer au thème clair",
"switchToDark": "Passer au thème sombre", "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": { "actions": {
"checkUpdates": "Vérifier les mises à jour", "checkUpdates": "Vérifier les mises à jour",
@@ -303,6 +314,7 @@
"downloads": "Téléchargements", "downloads": "Téléchargements",
"videoSettings": "Paramètres vidéo", "videoSettings": "Paramètres vidéo",
"layoutSettings": "Paramètres d'affichage", "layoutSettings": "Paramètres d'affichage",
"licenseIcons": "Icônes de licence",
"misc": "Divers", "misc": "Divers",
"backup": "Sauvegardes", "backup": "Sauvegardes",
"folderSettings": "Racines par défaut", "folderSettings": "Racines par défaut",
@@ -583,6 +595,10 @@
"label": "Masquer les mises à jour en accès anticipé", "label": "Masquer les mises à jour en accès anticipé",
"help": "Seulement 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": { "misc": {
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA", "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", "includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers",
@@ -1424,15 +1440,6 @@
"duplicate": "Ce tag existe déjà" "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": { "initialization": {
"title": "Initialisation", "title": "Initialisation",
"message": "Préparation de votre espace de travail...", "message": "Préparation de votre espace de travail...",
+17 -10
View File
@@ -251,7 +251,18 @@
"toggle": "החלף ערכת נושא", "toggle": "החלף ערכת נושא",
"switchToLight": "עבור לערכת נושא בהירה", "switchToLight": "עבור לערכת נושא בהירה",
"switchToDark": "עבור לערכת נושא כהה", "switchToDark": "עבור לערכת נושא כהה",
"switchToAuto": "עבור לערכת נושא אוטומטית" "switchToAuto": "עבור לערכת נושא אוטומטית",
"presets": "ערכות נושא מוגדרות",
"default": "ברירת מחדל",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "מצב",
"light": "בהיר",
"dark": "כהה",
"auto": "אוטומטי"
}, },
"actions": { "actions": {
"checkUpdates": "בדוק עדכונים", "checkUpdates": "בדוק עדכונים",
@@ -303,6 +314,7 @@
"downloads": "הורדות", "downloads": "הורדות",
"videoSettings": "הגדרות וידאו", "videoSettings": "הגדרות וידאו",
"layoutSettings": "הגדרות פריסה", "layoutSettings": "הגדרות פריסה",
"licenseIcons": "סמלי רישיון",
"misc": "שונות", "misc": "שונות",
"backup": "גיבויים", "backup": "גיבויים",
"folderSettings": "תיקיות ברירת מחדל", "folderSettings": "תיקיות ברירת מחדל",
@@ -583,6 +595,10 @@
"label": "הסתר עדכוני גישה מוקדמת", "label": "הסתר עדכוני גישה מוקדמת",
"help": "רק עדכוני גישה מוקדמת" "help": "רק עדכוני גישה מוקדמת"
}, },
"licenseIcons": {
"useNewStyle": "השתמש בסמלי רישיון מעודכנים",
"useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI."
},
"misc": { "misc": {
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA", "includeTriggerWords": "כלול מילות טריגר בתחביר LoRA",
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח", "includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח",
@@ -1424,15 +1440,6 @@
"duplicate": "תגית זו כבר קיימת" "duplicate": "תגית זו כבר קיימת"
} }
}, },
"keyboard": {
"navigation": "ניווט במקלדת:",
"shortcuts": {
"pageUp": "גלול עמוד אחד למעלה",
"pageDown": "גלול עמוד אחד למטה",
"home": "קפוץ להתחלה",
"end": "קפוץ לסוף"
}
},
"initialization": { "initialization": {
"title": "מאתחל", "title": "מאתחל",
"message": "מכין את סביבת העבודה שלך...", "message": "מכין את סביבת העבודה שלך...",
+17 -10
View File
@@ -251,7 +251,18 @@
"toggle": "テーマの切り替え", "toggle": "テーマの切り替え",
"switchToLight": "ライトテーマに切り替え", "switchToLight": "ライトテーマに切り替え",
"switchToDark": "ダークテーマに切り替え", "switchToDark": "ダークテーマに切り替え",
"switchToAuto": "自動テーマに切り替え" "switchToAuto": "自動テーマに切り替え",
"presets": "テーマプリセット",
"default": "デフォルト",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "モード",
"light": "ライト",
"dark": "ダーク",
"auto": "自動"
}, },
"actions": { "actions": {
"checkUpdates": "更新確認", "checkUpdates": "更新確認",
@@ -303,6 +314,7 @@
"downloads": "ダウンロード", "downloads": "ダウンロード",
"videoSettings": "動画設定", "videoSettings": "動画設定",
"layoutSettings": "レイアウト設定", "layoutSettings": "レイアウト設定",
"licenseIcons": "ライセンスアイコン",
"misc": "その他", "misc": "その他",
"backup": "バックアップ", "backup": "バックアップ",
"folderSettings": "デフォルトルート", "folderSettings": "デフォルトルート",
@@ -583,6 +595,10 @@
"label": "早期アクセス更新を非表示", "label": "早期アクセス更新を非表示",
"help": "早期アクセスのみの更新" "help": "早期アクセスのみの更新"
}, },
"licenseIcons": {
"useNewStyle": "更新されたライセンスアイコンを使用",
"useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。"
},
"misc": { "misc": {
"includeTriggerWords": "LoRA構文にトリガーワードを含める", "includeTriggerWords": "LoRA構文にトリガーワードを含める",
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます", "includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます",
@@ -1424,15 +1440,6 @@
"duplicate": "このタグは既に存在します" "duplicate": "このタグは既に存在します"
} }
}, },
"keyboard": {
"navigation": "キーボードナビゲーション:",
"shortcuts": {
"pageUp": "1ページ上にスクロール",
"pageDown": "1ページ下にスクロール",
"home": "トップにジャンプ",
"end": "ボトムにジャンプ"
}
},
"initialization": { "initialization": {
"title": "初期化中", "title": "初期化中",
"message": "ワークスペースを準備中...", "message": "ワークスペースを準備中...",
+17 -10
View File
@@ -251,7 +251,18 @@
"toggle": "테마 토글", "toggle": "테마 토글",
"switchToLight": "라이트 테마로 전환", "switchToLight": "라이트 테마로 전환",
"switchToDark": "다크 테마로 전환", "switchToDark": "다크 테마로 전환",
"switchToAuto": "자동 테마로 전환" "switchToAuto": "자동 테마로 전환",
"presets": "테마 프리셋",
"default": "기본",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "모드",
"light": "라이트",
"dark": "다크",
"auto": "자동"
}, },
"actions": { "actions": {
"checkUpdates": "업데이트 확인", "checkUpdates": "업데이트 확인",
@@ -303,6 +314,7 @@
"downloads": "다운로드", "downloads": "다운로드",
"videoSettings": "비디오 설정", "videoSettings": "비디오 설정",
"layoutSettings": "레이아웃 설정", "layoutSettings": "레이아웃 설정",
"licenseIcons": "라이선스 아이콘",
"misc": "기타", "misc": "기타",
"backup": "백업", "backup": "백업",
"folderSettings": "기본 루트", "folderSettings": "기본 루트",
@@ -583,6 +595,10 @@
"label": "얼리 액세스 업데이트 숨기기", "label": "얼리 액세스 업데이트 숨기기",
"help": "얼리 액세스 업데이트만" "help": "얼리 액세스 업데이트만"
}, },
"licenseIcons": {
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
"useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다."
},
"misc": { "misc": {
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함", "includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다", "includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다",
@@ -1424,15 +1440,6 @@
"duplicate": "이 태그는 이미 존재합니다" "duplicate": "이 태그는 이미 존재합니다"
} }
}, },
"keyboard": {
"navigation": "키보드 내비게이션:",
"shortcuts": {
"pageUp": "한 페이지 위로 스크롤",
"pageDown": "한 페이지 아래로 스크롤",
"home": "맨 위로 이동",
"end": "맨 아래로 이동"
}
},
"initialization": { "initialization": {
"title": "초기화 중", "title": "초기화 중",
"message": "작업공간을 준비하고 있습니다...", "message": "작업공간을 준비하고 있습니다...",
+17 -10
View File
@@ -251,7 +251,18 @@
"toggle": "Переключить тему", "toggle": "Переключить тему",
"switchToLight": "Переключить на светлую тему", "switchToLight": "Переключить на светлую тему",
"switchToDark": "Переключить на тёмную тему", "switchToDark": "Переключить на тёмную тему",
"switchToAuto": "Переключить на автоматическую тему" "switchToAuto": "Переключить на автоматическую тему",
"presets": "Предустановки тем",
"default": "По умолчанию",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "Режим",
"light": "Светлый",
"dark": "Тёмный",
"auto": "Авто"
}, },
"actions": { "actions": {
"checkUpdates": "Проверить обновления", "checkUpdates": "Проверить обновления",
@@ -303,6 +314,7 @@
"downloads": "Загрузки", "downloads": "Загрузки",
"videoSettings": "Настройки видео", "videoSettings": "Настройки видео",
"layoutSettings": "Настройки макета", "layoutSettings": "Настройки макета",
"licenseIcons": "Значки лицензии",
"misc": "Разное", "misc": "Разное",
"backup": "Резервные копии", "backup": "Резервные копии",
"folderSettings": "Корневые папки", "folderSettings": "Корневые папки",
@@ -583,6 +595,10 @@
"label": "Скрыть обновления раннего доступа", "label": "Скрыть обновления раннего доступа",
"help": "Только обновления раннего доступа" "help": "Только обновления раннего доступа"
}, },
"licenseIcons": {
"useNewStyle": "Использовать обновлённые значки лицензии",
"useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI."
},
"misc": { "misc": {
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA", "includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена", "includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена",
@@ -1424,15 +1440,6 @@
"duplicate": "Этот тег уже существует" "duplicate": "Этот тег уже существует"
} }
}, },
"keyboard": {
"navigation": "Навигация с клавиатуры:",
"shortcuts": {
"pageUp": "Прокрутить на страницу вверх",
"pageDown": "Прокрутить на страницу вниз",
"home": "Перейти к началу",
"end": "Перейти к концу"
}
},
"initialization": { "initialization": {
"title": "Инициализация", "title": "Инициализация",
"message": "Подготовка вашего рабочего пространства...", "message": "Подготовка вашего рабочего пространства...",
+17 -10
View File
@@ -251,7 +251,18 @@
"toggle": "切换主题", "toggle": "切换主题",
"switchToLight": "切换到浅色主题", "switchToLight": "切换到浅色主题",
"switchToDark": "切换到深色主题", "switchToDark": "切换到深色主题",
"switchToAuto": "切换到自动主题" "switchToAuto": "切换到自动主题",
"presets": "主题预设",
"default": "默认",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "模式",
"light": "浅色",
"dark": "深色",
"auto": "自动"
}, },
"actions": { "actions": {
"checkUpdates": "检查更新", "checkUpdates": "检查更新",
@@ -303,6 +314,7 @@
"downloads": "下载", "downloads": "下载",
"videoSettings": "视频设置", "videoSettings": "视频设置",
"layoutSettings": "布局设置", "layoutSettings": "布局设置",
"licenseIcons": "许可协议图标",
"misc": "其他", "misc": "其他",
"backup": "备份", "backup": "备份",
"folderSettings": "默认根目录", "folderSettings": "默认根目录",
@@ -583,6 +595,10 @@
"label": "隐藏抢先体验更新", "label": "隐藏抢先体验更新",
"help": "抢先体验更新" "help": "抢先体验更新"
}, },
"licenseIcons": {
"useNewStyle": "使用新版许可协议图标",
"useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。"
},
"misc": { "misc": {
"includeTriggerWords": "复制 LoRA 语法时包含触发词", "includeTriggerWords": "复制 LoRA 语法时包含触发词",
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词", "includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词",
@@ -1424,15 +1440,6 @@
"duplicate": "该标签已存在" "duplicate": "该标签已存在"
} }
}, },
"keyboard": {
"navigation": "键盘导航:",
"shortcuts": {
"pageUp": "向上一页滚动",
"pageDown": "向下一页滚动",
"home": "跳到顶部",
"end": "跳到底部"
}
},
"initialization": { "initialization": {
"title": "初始化", "title": "初始化",
"message": "正在准备你的工作空间...", "message": "正在准备你的工作空间...",
+17 -10
View File
@@ -251,7 +251,18 @@
"toggle": "切換主題", "toggle": "切換主題",
"switchToLight": "切換至淺色主題", "switchToLight": "切換至淺色主題",
"switchToDark": "切換至深色主題", "switchToDark": "切換至深色主題",
"switchToAuto": "自動主題" "switchToAuto": "自動主題",
"presets": "主題預設",
"default": "預設",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "模式",
"light": "淺色",
"dark": "深色",
"auto": "自動"
}, },
"actions": { "actions": {
"checkUpdates": "檢查更新", "checkUpdates": "檢查更新",
@@ -303,6 +314,7 @@
"downloads": "下載", "downloads": "下載",
"videoSettings": "影片設定", "videoSettings": "影片設定",
"layoutSettings": "版面設定", "layoutSettings": "版面設定",
"licenseIcons": "許可協議圖標",
"misc": "其他", "misc": "其他",
"backup": "備份", "backup": "備份",
"folderSettings": "預設根目錄", "folderSettings": "預設根目錄",
@@ -583,6 +595,10 @@
"label": "隱藏搶先體驗更新", "label": "隱藏搶先體驗更新",
"help": "搶先體驗更新" "help": "搶先體驗更新"
}, },
"licenseIcons": {
"useNewStyle": "使用新版許可協議圖標",
"useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。"
},
"misc": { "misc": {
"includeTriggerWords": "在 LoRA 語法中包含觸發詞", "includeTriggerWords": "在 LoRA 語法中包含觸發詞",
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞", "includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞",
@@ -1424,15 +1440,6 @@
"duplicate": "此標籤已存在" "duplicate": "此標籤已存在"
} }
}, },
"keyboard": {
"navigation": "鍵盤導覽:",
"shortcuts": {
"pageUp": "向上捲動一頁",
"pageDown": "向下捲動一頁",
"home": "跳至頂部",
"end": "跳至底部"
}
},
"initialization": { "initialization": {
"title": "初始化", "title": "初始化",
"message": "正在準備您的工作區...", "message": "正在準備您的工作區...",
+42
View File
@@ -1272,6 +1272,14 @@ class ModelQueryHandler:
license_flags = (model_data or {}).get("license_flags") license_flags = (model_data or {}).get("license_flags")
if license_flags is not None: if license_flags is not None:
response_payload["license_flags"] = int(license_flags) 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(response_payload)
return web.json_response( return web.json_response(
{ {
@@ -1820,6 +1828,39 @@ class ModelDownloadHandler:
) )
return web.json_response({"success": False, "error": str(exc)}, status=500) 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: class ModelCivitaiHandler:
"""CivitAI integration endpoints.""" """CivitAI integration endpoints."""
@@ -2864,6 +2905,7 @@ class ModelHandlerSet:
"retry_all_failed_downloads": self.download.retry_all_failed_downloads, "retry_all_failed_downloads": self.download.retry_all_failed_downloads,
"complete_download_in_queue": self.download.complete_download_in_queue, "complete_download_in_queue": self.download.complete_download_in_queue,
"get_download_stats": self.download.get_download_stats, "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_versions": self.civitai.get_civitai_versions,
"get_civitai_model_by_version": self.civitai.get_civitai_model_by_version, "get_civitai_model_by_version": self.civitai.get_civitai_model_by_version,
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash, "get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,
+3
View File
@@ -138,6 +138,9 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition( RouteDefinition(
"GET", "/api/lm/downloads/queue/complete", "complete_download_in_queue" "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("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
RouteDefinition("GET", "/{prefix}", "handle_models_page"), RouteDefinition("GET", "/{prefix}", "handle_models_page"),
) )
+19 -1
View File
@@ -11,6 +11,8 @@ from ..config import config
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
from ..services.server_i18n import server_i18n from ..services.server_i18n import server_i18n
from ..services.service_registry import ServiceRegistry 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 from ..utils.usage_stats import UsageStats
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -140,6 +142,21 @@ class StatsRoutes:
# Get usage statistics # Get usage statistics
usage_data = await self.usage_stats.get_stats() 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({ return web.json_response({
'success': True, 'success': True,
'data': { 'data': {
@@ -154,7 +171,8 @@ class StatsRoutes:
'total_generations': usage_data.get('total_executions', 0), 'total_generations': usage_data.get('total_executions', 0),
'unused_loras': self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {})), '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_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())
} }
}) })
+55
View File
@@ -29,6 +29,7 @@ from .metadata_service import get_default_metadata_provider, get_metadata_provid
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
from .aria2_downloader import Aria2Error, get_aria2_downloader from .aria2_downloader import Aria2Error, get_aria2_downloader
from .aria2_transfer_state import Aria2TransferStateStore from .aria2_transfer_state import Aria2TransferStateStore
from .download_queue_service import DownloadQueueService
# Download to temporary file first # Download to temporary file first
import tempfile import tempfile
@@ -360,6 +361,15 @@ class DownloadManager:
if self._active_downloads[task_id].get("transfer_backend") == "aria2": if self._active_downloads[task_id].get("transfer_backend") == "aria2":
await self._persist_aria2_state(task_id) 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 # Use original download implementation
try: try:
# Check for cancellation before starting # Check for cancellation before starting
@@ -396,6 +406,22 @@ class DownloadManager:
if self._active_downloads[task_id].get("transfer_backend") == "aria2": if self._active_downloads[task_id].get("transfer_backend") == "aria2":
await self._persist_aria2_state(task_id) 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 return result
except asyncio.CancelledError: except asyncio.CancelledError:
# Handle cancellation # Handle cancellation
@@ -404,6 +430,19 @@ class DownloadManager:
self._active_downloads[task_id]["bytes_per_second"] = 0.0 self._active_downloads[task_id]["bytes_per_second"] = 0.0
if self._active_downloads[task_id].get("transfer_backend") == "aria2": if self._active_downloads[task_id].get("transfer_backend") == "aria2":
await self._persist_aria2_state(task_id) 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}") logger.info(f"Download cancelled for task {task_id}")
raise raise
except Exception as e: except Exception as e:
@@ -417,6 +456,22 @@ class DownloadManager:
self._active_downloads[task_id]["bytes_per_second"] = 0.0 self._active_downloads[task_id]["bytes_per_second"] = 0.0
if self._active_downloads[task_id].get("transfer_backend") == "aria2": if self._active_downloads[task_id].get("transfer_backend") == "aria2":
await self._persist_aria2_state(task_id) 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)} return {"success": False, "error": str(e)}
finally: finally:
# Schedule cleanup of download record after delay # Schedule cleanup of download record after delay
+1
View File
@@ -105,6 +105,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"download_skip_base_models": [], "download_skip_base_models": [],
"backup_auto_enabled": True, "backup_auto_enabled": True,
"backup_retention_count": 5, "backup_retention_count": 5,
"use_new_license_icons": True,
} }
+6 -1
View File
@@ -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 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 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. 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() sha256_hash = hashlib.sha256()
chunk_size = _get_hash_chunk_size_bytes() chunk_size = _get_hash_chunk_size_bytes()
@@ -48,7 +51,9 @@ async def calculate_sha256(file_path: str) -> str:
sha256_hash.update(byte_block) sha256_hash.update(byte_block)
# Evict pages after reading so the data doesn't linger in the kernel page # Evict pages after reading so the data doesn't linger in the kernel page
# cache — on WSL this otherwise appears as unreclaimable VmmemWSL growth. # 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() return sha256_hash.hexdigest()
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.1.2" version = "1.1.3"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",
+47 -53
View File
@@ -349,8 +349,8 @@
} }
.progress-percentage { .progress-percentage {
font-size: 1.2em; font-size: var(--text-lg);
font-weight: 600; font-weight: var(--weight-semibold);
color: var(--lora-accent); color: var(--lora-accent);
} }
@@ -365,9 +365,9 @@
.progress-bar { .progress-bar {
height: 100%; height: 100%;
background: linear-gradient(90deg, var(--lora-accent), oklch(from var(--lora-accent) calc(l + 0.1) c h)); background: var(--lora-accent);
border-radius: 4px; border-radius: var(--border-radius-xs);
transition: width 0.3s ease; transition: width var(--transition-base);
} }
/* Progress Stats */ /* Progress Stats */
@@ -389,27 +389,26 @@
} }
.stat-item.success { .stat-item.success {
border-left: 3px solid #00B87A; border-left: 4px solid var(--color-success);
} }
.stat-item.failed { .stat-item.failed {
border-left: 3px solid var(--lora-error); border-left: 4px solid var(--color-error);
} }
.stat-item.skipped { .stat-item.skipped {
border-left: 3px solid var(--lora-warning); border-left: 4px solid var(--color-warning);
} }
.stat-label { .stat-label {
font-size: 0.8em; font-size: var(--text-xs);
color: var(--text-color); color: var(--text-secondary);
opacity: 0.7;
margin-bottom: 4px; margin-bottom: 4px;
} }
.stat-value { .stat-value {
font-size: 1.4em; font-size: var(--text-lg);
font-weight: 600; font-weight: var(--weight-semibold);
color: var(--text-color); color: var(--text-color);
} }
@@ -425,8 +424,7 @@
} }
.current-item-label { .current-item-label {
color: var(--text-color); color: var(--text-secondary);
opacity: 0.7;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -449,27 +447,29 @@
} }
.results-header { .results-header {
text-align: center; display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
} }
.results-icon { .results-icon {
font-size: 3em; font-size: var(--text-xl);
color: #00B87A; color: var(--color-success);
margin-bottom: var(--space-1); flex-shrink: 0;
} }
.results-icon.warning { .results-icon.warning {
color: var(--lora-warning); color: var(--color-warning);
} }
.results-icon.error { .results-icon.error {
color: var(--lora-error); color: var(--color-error);
} }
.results-title { .results-title {
font-size: 1.3em; font-size: var(--text-lg);
font-weight: 600; font-weight: var(--weight-semibold);
color: var(--text-color); color: var(--text-color);
} }
@@ -493,27 +493,26 @@
} }
.result-card.success { .result-card.success {
border-left: 3px solid #00B87A; border-left: 4px solid var(--color-success);
} }
.result-card.failed { .result-card.failed {
border-left: 3px solid var(--lora-error); border-left: 4px solid var(--color-error);
} }
.result-card.skipped { .result-card.skipped {
border-left: 3px solid var(--lora-warning); border-left: 4px solid var(--color-warning);
} }
.result-label { .result-label {
font-size: 0.8em; font-size: var(--text-xs);
color: var(--text-color); color: var(--text-secondary);
opacity: 0.7;
margin-bottom: 4px; margin-bottom: 4px;
} }
.result-value { .result-value {
font-size: 1.4em; font-size: var(--text-lg);
font-weight: 600; font-weight: var(--weight-semibold);
color: var(--text-color); color: var(--text-color);
} }
@@ -527,13 +526,13 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: var(--space-2);
padding: 10px; padding: var(--space-2);
cursor: pointer; cursor: pointer;
color: var(--lora-accent); color: var(--lora-accent);
font-weight: 500; font-weight: var(--weight-medium);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
transition: background 0.2s; transition: background var(--transition-base);
} }
.details-toggle:hover { .details-toggle:hover {
@@ -541,7 +540,7 @@
} }
.details-toggle i { .details-toggle i {
transition: transform 0.2s; transition: transform var(--transition-base);
} }
.details-toggle.expanded i { .details-toggle.expanded i {
@@ -561,10 +560,10 @@
.result-item { .result-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: var(--space-2);
padding: 10px 12px; padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
font-size: 0.9em; font-size: var(--text-sm);
} }
.result-item:last-child { .result-item:last-child {
@@ -572,28 +571,23 @@
} }
.result-item-status { .result-item-status {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 0.8em; font-size: var(--text-sm);
flex-shrink: 0;
} }
.result-item-status.success { .result-item-status.success {
background: oklch(from #00B87A l c h / 0.2); color: var(--color-success);
color: #00B87A;
} }
.result-item-status.failed { .result-item-status.failed {
background: oklch(from var(--lora-error) l c h / 0.2); color: var(--color-error);
color: var(--lora-error);
} }
.result-item-status.skipped { .result-item-status.skipped {
background: oklch(from var(--lora-warning) l c h / 0.2); color: var(--color-warning);
color: var(--lora-warning);
} }
.result-item-info { .result-item-info {
@@ -610,8 +604,8 @@
} }
.result-item-error { .result-item-error {
font-size: 0.8em; font-size: var(--text-xs);
color: var(--lora-error); color: var(--color-error);
margin-top: 2px; margin-top: 2px;
} }
@@ -661,11 +655,11 @@
/* Completed State */ /* Completed State */
.batch-progress-container.completed .progress-bar { .batch-progress-container.completed .progress-bar {
background: #00B87A; background: var(--color-success);
} }
.batch-progress-container.completed .status-icon { .batch-progress-container.completed .status-icon {
color: #00B87A; color: var(--color-success);
} }
.batch-progress-container.completed .status-icon i { .batch-progress-container.completed .status-icon i {
+27 -27
View File
@@ -5,10 +5,10 @@
position: sticky; /* Keep the sticky position */ position: sticky; /* Keep the sticky position */
top: var(--space-1); top: var(--space-1);
width: 100%; 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); 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-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(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4); /* Make bottom border stronger */ 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); z-index: var(--z-overlay);
padding: 12px 0; padding: 12px 0;
box-shadow: var(--shadow-lg); /* Stronger shadow */ box-shadow: var(--shadow-lg); /* Stronger shadow */
@@ -41,7 +41,7 @@
.duplicates-banner i.fa-exclamation-triangle { .duplicates-banner i.fa-exclamation-triangle {
font-size: 18px; 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 { .duplicates-banner .banner-actions {
@@ -70,7 +70,7 @@
.duplicates-banner button.btn-exit-mode:hover { .duplicates-banner button.btn-exit-mode:hover {
background-color: var(--bg-color); 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); transform: translateY(-1px);
} }
@@ -92,7 +92,7 @@
} }
.duplicates-banner button:hover { .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); background: var(--bg-color);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
@@ -117,7 +117,7 @@
/* Duplicate groups */ /* Duplicate groups */
.duplicate-group { .duplicate-group {
position: relative; 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); border-radius: var(--border-radius-base);
padding: 16px; padding: 16px;
margin-bottom: 24px; margin-bottom: 24px;
@@ -152,7 +152,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; 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 { .duplicate-group-header span:last-child {
@@ -180,7 +180,7 @@
} }
.duplicate-group-header button:hover { .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); background: var(--bg-color);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
@@ -235,7 +235,7 @@
} }
.group-toggle-btn:hover { .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); transform: translateY(-1px);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
@@ -247,16 +247,16 @@
} }
.model-card.duplicate:hover { .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 { .model-card.duplicate.latest {
border-style: solid; 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 { .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); box-shadow: var(--shadow-md);
} }
@@ -276,7 +276,7 @@
position: absolute; position: absolute;
top: 10px; top: 10px;
left: 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; color: white;
font-size: 12px; font-size: 12px;
padding: 2px 6px; padding: 2px 6px;
@@ -328,7 +328,7 @@
margin-top: 8px; margin-top: 8px;
padding-top: 8px; padding-top: 8px;
border-top: 1px dashed var(--border-color); 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; font-weight: bold;
word-break: break-all; /* Ensure long hashes wrap properly */ word-break: break-all; /* Ensure long hashes wrap properly */
} }
@@ -351,12 +351,12 @@
} }
.verification-badge.verified { .verification-badge.verified {
background-color: oklch(70% 0.2 140); /* Green for verified */ background-color: var(--color-success); /* Green for verified */
color: white; color: white;
} }
.verification-badge.mismatch { .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; color: white;
} }
@@ -366,7 +366,7 @@
/* Hash Mismatch Styling */ /* Hash Mismatch Styling */
.model-card.duplicate.hash-mismatch { .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; opacity: 0.85;
position: relative; position: relative;
} }
@@ -380,8 +380,8 @@
bottom: 0; bottom: 0;
background: repeating-linear-gradient( background: repeating-linear-gradient(
45deg, 45deg,
oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h) / 0.05), oklch(var(--color-warning-l) var(--color-warning-c) var(--color-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) 10px,
transparent 10px, transparent 10px,
transparent 20px transparent 20px
); );
@@ -398,7 +398,7 @@
position: absolute; position: absolute;
top: 10px; top: 10px;
left: 10px; /* Changed from right:10px to left: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; color: white;
font-size: 12px; font-size: 12px;
padding: 3px 8px; padding: 3px 8px;
@@ -417,7 +417,7 @@
margin-top: 8px; margin-top: 8px;
padding-top: 8px; padding-top: 8px;
border-top: 1px dashed var(--border-color); 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; font-weight: bold;
} }
@@ -437,7 +437,7 @@
.btn-verify-hashes:hover { .btn-verify-hashes:hover {
background: var(--bg-color); 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); transform: translateY(-1px);
} }
@@ -498,7 +498,7 @@
.help-icon:hover { .help-icon:hover {
opacity: 1; 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 */ /* Help tooltip */
@@ -573,7 +573,7 @@
/* In dark mode, add additional distinction */ /* In dark mode, add additional distinction */
html[data-theme="dark"] .duplicates-banner { html[data-theme="dark"] .duplicates-banner {
box-shadow: var(--shadow-dark-lg); /* Stronger shadow in dark mode */ 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 { html[data-theme="dark"] .duplicate-group {
@@ -598,11 +598,11 @@ html[data-theme="dark"] .help-tooltip {
background: var(--lora-accent); background: var(--lora-accent);
color: white; color: white;
border-color: var(--lora-accent); 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; position: relative;
z-index: 5; z-index: 5;
} }
#findDuplicatesBtn.active:hover { #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));
} }
+195 -6
View File
@@ -283,7 +283,6 @@
.theme-toggle { .theme-toggle {
position: relative; position: relative;
/* Ensure relative positioning for the container */
} }
.theme-toggle .light-icon, .theme-toggle .light-icon,
@@ -293,17 +292,14 @@
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
/* Center perfectly */
opacity: 0; opacity: 0;
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
} }
/* Default state shows dark icon */
.theme-toggle .dark-icon { .theme-toggle .dark-icon {
opacity: 1; opacity: 1;
} }
/* Light theme shows light icon */
.theme-toggle.theme-light .light-icon { .theme-toggle.theme-light .light-icon {
opacity: 1; opacity: 1;
} }
@@ -313,7 +309,6 @@
opacity: 0; opacity: 0;
} }
/* Dark theme shows dark icon */
.theme-toggle.theme-dark .dark-icon { .theme-toggle.theme-dark .dark-icon {
opacity: 1; opacity: 1;
} }
@@ -323,7 +318,6 @@
opacity: 0; opacity: 0;
} }
/* Auto theme shows auto icon */
.theme-toggle.theme-auto .auto-icon { .theme-toggle.theme-auto .auto-icon {
opacity: 1; opacity: 1;
} }
@@ -333,6 +327,201 @@
opacity: 0; 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 */ /* Badge styling */
.update-badge { .update-badge {
position: absolute; position: absolute;
+4 -4
View File
@@ -211,7 +211,7 @@
.lora-item.is-early-access { .lora-item.is-early-access {
background: rgba(0, 184, 122, 0.05); background: rgba(0, 184, 122, 0.05);
border-left: 4px solid #00B87A; border-left: 4px solid var(--color-success);
} }
.lora-item.missing-locally { .lora-item.missing-locally {
@@ -310,7 +310,7 @@
.missing-lora-item.is-early-access { .missing-lora-item.is-early-access {
background: rgba(0, 184, 122, 0.05); background: rgba(0, 184, 122, 0.05);
border-left: 3px solid #00B87A; border-left: 3px solid var(--color-success);
padding-left: 10px; padding-left: 10px;
} }
@@ -630,7 +630,7 @@
gap: 12px; gap: 12px;
padding: 12px 16px; padding: 12px 16px;
background: rgba(0, 184, 122, 0.1); background: rgba(0, 184, 122, 0.1);
border: 1px solid #00B87A; border: 1px solid var(--color-success);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
color: var(--text-color); color: var(--text-color);
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
@@ -646,7 +646,7 @@
/* Specific styling for the early access warning container in import modal */ /* Specific styling for the early access warning container in import modal */
.early-access-warning .warning-icon { .early-access-warning .warning-icon {
color: #00B87A; color: var(--color-success);
font-size: 1.2em; font-size: 1.2em;
} }
-96
View File
@@ -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; margin-left: auto;
} }
.modal-header-actions .license-permissions {
margin-left: auto;
}
.license-restrictions { .license-restrictions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -95,6 +99,41 @@
transform: translateY(-1px); 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 */
.info-grid { .info-grid {
display: grid; display: grid;
@@ -24,11 +24,6 @@
min-width: 130px; min-width: 130px;
} }
.stat-card > i {
font-size: 1.25em;
flex-shrink: 0;
}
.stat-card-body { .stat-card-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -52,40 +47,20 @@
border-left-color: var(--color-success); border-left-color: var(--color-success);
} }
.stat-card-success > i {
color: var(--color-success);
}
.stat-card-failure { .stat-card-failure {
border-left-color: var(--color-error); border-left-color: var(--color-error);
} }
.stat-card-failure > i {
color: var(--color-error);
}
.stat-card-skipped { .stat-card-skipped {
border-left-color: var(--color-warning); border-left-color: var(--color-warning);
} }
.stat-card-skipped > i {
color: var(--color-warning);
}
.stat-card-total { .stat-card-total {
border-left-color: var(--color-info); border-left-color: var(--lora-border);
}
.stat-card-total > i {
color: var(--color-info);
} }
.stat-card-time { .stat-card-time {
border-left-color: var(--color-accent); border-left-color: var(--lora-border);
}
.stat-card-time > i {
color: var(--color-accent);
} }
.refresh-failures-section { .refresh-failures-section {
@@ -122,7 +97,7 @@
position: sticky; position: sticky;
top: 0; top: 0;
background: var(--lora-surface); 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); padding: var(--space-1) var(--space-2);
text-align: left; text-align: left;
font-weight: var(--weight-semibold); font-weight: var(--weight-semibold);
+67 -14
View File
@@ -823,54 +823,107 @@
} }
.range-control input[type="range"] { .range-control input[type="range"] {
--range-fill: 40%;
width: 120px; width: 120px;
height: 4px; height: 6px;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
background: var(--border-color); background: linear-gradient(
border-radius: 2px; 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; outline: none;
cursor: pointer; cursor: pointer;
flex-shrink: 0; 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 { .range-control input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 16px; width: 18px;
height: 16px; height: 18px;
border-radius: 50%; border-radius: 50%;
background: var(--lora-accent); background: var(--lora-accent);
cursor: pointer; cursor: pointer;
border: 2px solid var(--lora-surface); border: 2px solid var(--lora-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); box-shadow: var(--shadow-md);
transition: transform 0.15s ease; transition: transform var(--transition-bounce), box-shadow 0.2s ease;
} }
.range-control input[type="range"]::-webkit-slider-thumb:hover { .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 { .range-control input[type="range"]::-moz-range-thumb {
width: 16px; width: 18px;
height: 16px; height: 18px;
border-radius: 50%; border-radius: 50%;
background: var(--lora-accent); background: var(--lora-accent);
cursor: pointer; cursor: pointer;
border: 2px solid var(--lora-surface); 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 { .range-control .range-value {
min-width: 36px; min-width: 36px;
text-align: center; text-align: center;
font-size: 0.9em; font-size: 0.85em;
font-weight: 600; font-weight: 700;
color: var(--text-color); color: var(--lora-accent);
font-variant-numeric: tabular-nums; 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"] { [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); background: rgba(255, 255, 255, 0.15);
} }
+18 -4
View File
@@ -114,11 +114,10 @@
.sidebar-hidden-indicator { .sidebar-hidden-indicator {
position: fixed; position: fixed;
left: 0; left: 0;
top: 50%; top: 68px; /* Align with sidebar header */
transform: translateY(-50%);
z-index: var(--z-overlay); z-index: var(--z-overlay);
width: 14px; width: 14px;
height: 44px; height: 48px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -135,7 +134,7 @@
} }
.sidebar-hidden-indicator i { .sidebar-hidden-indicator i {
font-size: 9px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
transition: color 0.15s ease; transition: color 0.15s ease;
} }
@@ -144,6 +143,21 @@
color: white; 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 { .sidebar-hidden-indicator-tooltip {
position: absolute; position: absolute;
left: 100%; left: 100%;
+6 -6
View File
@@ -27,8 +27,8 @@
transition: var(--transition-slow); transition: var(--transition-slow);
/* Add glow effect */ /* Add glow effect */
box-shadow: box-shadow:
0 0 0 2px rgba(24, 144, 255, 0.3), 0 0 0 2px color-mix(in oklch, var(--color-accent) 30%, transparent),
0 0 20px rgba(24, 144, 255, 0.2), 0 0 20px color-mix(in oklch, var(--color-accent) 20%, transparent),
inset 0 0 0 1px rgba(255, 255, 255, 0.1); inset 0 0 0 1px rgba(255, 255, 255, 0.1);
} }
@@ -221,14 +221,14 @@
@keyframes onboarding-pulse { @keyframes onboarding-pulse {
0%, 100% { 0%, 100% {
box-shadow: box-shadow:
0 0 0 2px rgba(24, 144, 255, 0.4), 0 0 0 2px color-mix(in oklch, var(--color-accent) 40%, transparent),
0 0 20px rgba(24, 144, 255, 0.3), 0 0 20px color-mix(in oklch, var(--color-accent) 30%, transparent),
inset 0 0 0 1px rgba(255, 255, 255, 0.1); inset 0 0 0 1px rgba(255, 255, 255, 0.1);
} }
50% { 50% {
box-shadow: box-shadow:
0 0 0 4px rgba(24, 144, 255, 0.6), 0 0 0 4px color-mix(in oklch, var(--color-accent) 60%, transparent),
0 0 30px rgba(24, 144, 255, 0.4), 0 0 30px color-mix(in oklch, var(--color-accent) 40%, transparent),
inset 0 0 0 1px rgba(255, 255, 255, 0.2); inset 0 0 0 1px rgba(255, 255, 255, 0.2);
} }
} }
+1 -1
View File
@@ -36,7 +36,7 @@
@import 'components/initialization.css'; @import 'components/initialization.css';
@import 'components/progress-panel.css'; @import 'components/progress-panel.css';
@import 'components/duplicates.css'; /* Add duplicates component */ @import 'components/duplicates.css'; /* Add duplicates component */
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
@import 'components/statistics.css'; /* Add statistics component */ @import 'components/statistics.css'; /* Add statistics component */
@import 'components/sidebar.css'; /* Add sidebar component */ @import 'components/sidebar.css'; /* Add sidebar component */
@import 'components/media-viewer.css'; @import 'components/media-viewer.css';
+357 -9
View File
@@ -37,13 +37,13 @@
--color-error-border: color-mix(in oklch, var(--color-error) 50%, transparent); --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: oklch(var(--color-info-l) var(--color-info-c) var(--color-info-h));
--color-info-bg: oklch(72% 0.2 220); --color-info-bg: oklch(var(--color-info-l) var(--color-info-c) var(--color-info-h));
--color-info-text: oklch(28% 0.03 220); --color-info-text: oklch(28% 0.03 var(--color-info-h));
--color-info-glow: oklch(72% 0.2 220 / 0.28); --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-bg: oklch(82% 0.12 var(--color-warning-h));
--color-skip-refresh-text: oklch(35% 0.02 45); --color-skip-refresh-text: oklch(35% 0.02 var(--color-warning-h));
--color-skip-refresh-glow: oklch(82% 0.12 45 / 0.15); --color-skip-refresh-glow: oklch(82% 0.12 var(--color-warning-h) / 0.15);
} }
:root { :root {
@@ -106,12 +106,360 @@
--status-info-bg: oklch(50% 0.10 190 / 0.25); --status-info-bg: oklch(50% 0.10 190 / 0.25);
--status-info-border: oklch(55% 0.12 195 / 0.3); --status-info-border: oklch(55% 0.12 195 / 0.3);
--color-info-bg: oklch(62% 0.18 220); --color-info-bg: oklch(62% 0.18 var(--color-info-h));
--color-info-text: oklch(98% 0.02 240); --color-info-text: oklch(98% 0.02 var(--color-info-h));
--color-info-glow: oklch(62% 0.18 220 / 0.4); --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-bg: color-mix(in oklch, var(--color-error) 15%, transparent);
--color-error-border: color-mix(in oklch, var(--color-error) 40%, transparent); --color-error-border: color-mix(in oklch, var(--color-error) 40%, transparent);
--favorite-color: #ffc107; --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
View 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
View 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
View 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
View 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
View 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

+1 -6
View File
@@ -679,39 +679,34 @@ export class BaseModelApiClient {
<div class="modal-content metadata-refresh-result-modal"> <div class="modal-content metadata-refresh-result-modal">
<button class="close" data-action="close-modal">&times;</button> <button class="close" data-action="close-modal">&times;</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="refresh-summary-stats">
<div class="stat-card stat-card-success"> <div class="stat-card stat-card-success">
<i class="fas fa-check-circle"></i>
<div class="stat-card-body"> <div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statSuccess', {}, 'Success')}</span> <span class="stat-card-label">${translate('modals.metadataFetchSummary.statSuccess', {}, 'Success')}</span>
<span class="stat-card-value">${success}</span> <span class="stat-card-value">${success}</span>
</div> </div>
</div> </div>
<div class="stat-card stat-card-failure"> <div class="stat-card stat-card-failure">
<i class="fas fa-times-circle"></i>
<div class="stat-card-body"> <div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statFailed', {}, 'Failed')}</span> <span class="stat-card-label">${translate('modals.metadataFetchSummary.statFailed', {}, 'Failed')}</span>
<span class="stat-card-value">${failure_count}</span> <span class="stat-card-value">${failure_count}</span>
</div> </div>
</div> </div>
<div class="stat-card stat-card-skipped"> <div class="stat-card stat-card-skipped">
<i class="fas fa-forward"></i>
<div class="stat-card-body"> <div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statSkipped', {}, 'Skipped')}</span> <span class="stat-card-label">${translate('modals.metadataFetchSummary.statSkipped', {}, 'Skipped')}</span>
<span class="stat-card-value">${skipped_count}</span> <span class="stat-card-value">${skipped_count}</span>
</div> </div>
</div> </div>
<div class="stat-card stat-card-total"> <div class="stat-card stat-card-total">
<i class="fas fa-database"></i>
<div class="stat-card-body"> <div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statTotal', {}, 'Total Scanned')}</span> <span class="stat-card-label">${translate('modals.metadataFetchSummary.statTotal', {}, 'Total Scanned')}</span>
<span class="stat-card-value">${total || processed}</span> <span class="stat-card-value">${total || processed}</span>
</div> </div>
</div> </div>
<div class="stat-card stat-card-time"> <div class="stat-card stat-card-time">
<i class="fas fa-clock"></i>
<div class="stat-card-body"> <div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statDuration', {}, 'Duration')}</span> <span class="stat-card-label">${translate('modals.metadataFetchSummary.statDuration', {}, 'Duration')}</span>
<span class="stat-card-value">${elapsed_seconds}s</span> <span class="stat-card-value">${elapsed_seconds}s</span>
+138 -55
View File
@@ -1,9 +1,9 @@
import { updateService } from '../managers/UpdateService.js'; 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 { SearchManager } from '../managers/SearchManager.js';
import { FilterManager } from '../managers/FilterManager.js'; import { FilterManager } from '../managers/FilterManager.js';
import { initPageState } from '../state/index.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 { updateElementAttribute } from '../utils/i18nHelpers.js';
import { renderSupporters } from '../services/supportersService.js'; import { renderSupporters } from '../services/supportersService.js';
@@ -47,25 +47,8 @@ export class HeaderManager {
} }
initializeCommonElements() { initializeCommonElements() {
// Handle theme toggle this.initializeThemePopover();
const themeToggle = document.querySelector('.theme-toggle');
if (themeToggle) {
const currentTheme = getStorageItem('theme') || 'auto';
themeToggle.classList.add(`theme-${currentTheme}`);
// 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'); const settingsToggle = document.querySelector('.settings-toggle');
if (settingsToggle) { if (settingsToggle) {
settingsToggle.addEventListener('click', () => { settingsToggle.addEventListener('click', () => {
@@ -74,22 +57,19 @@ export class HeaderManager {
} }
}); });
} }
// Handle update toggle
const updateToggle = document.getElementById('updateToggleBtn'); const updateToggle = document.getElementById('updateToggleBtn');
if (updateToggle) { if (updateToggle) {
updateToggle.addEventListener('click', () => { updateToggle.addEventListener('click', () => {
updateService.toggleUpdateModal(); updateService.toggleUpdateModal();
}); });
} }
// Handle support toggle
const supportToggle = document.getElementById('supportToggleBtn'); const supportToggle = document.getElementById('supportToggleBtn');
if (supportToggle) { if (supportToggle) {
supportToggle.addEventListener('click', async () => { supportToggle.addEventListener('click', async () => {
if (window.modalManager) { if (window.modalManager) {
window.modalManager.toggleModal('supportModal'); window.modalManager.toggleModal('supportModal');
// Load supporters data when modal opens
try { try {
await renderSupporters(); await renderSupporters();
} catch (error) { } catch (error) {
@@ -99,41 +79,144 @@ export class HeaderManager {
}); });
} }
// Handle QR code toggle
const qrToggle = document.getElementById('toggleQRCode'); const qrToggle = document.getElementById('toggleQRCode');
const qrContainer = document.getElementById('qrCodeContainer'); 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(); 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() { initializeHamburgerMenu() {
const hamburgerBtn = document.getElementById('hamburgerMenuBtn'); const hamburgerBtn = document.getElementById('hamburgerMenuBtn');
const hamburgerDropdown = document.getElementById('hamburgerDropdown'); const hamburgerDropdown = document.getElementById('hamburgerDropdown');
@@ -188,7 +271,6 @@ export class HeaderManager {
case 'theme': case 'theme':
if (typeof toggleTheme === 'function') { if (typeof toggleTheme === 'function') {
const newTheme = toggleTheme(); const newTheme = toggleTheme();
// Update theme toggle in header if it exists
const themeToggle = document.querySelector('.theme-toggle'); const themeToggle = document.querySelector('.theme-toggle');
if (themeToggle) { if (themeToggle) {
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto'); themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
@@ -196,6 +278,7 @@ export class HeaderManager {
this.updateThemeTooltip(themeToggle, newTheme); this.updateThemeTooltip(themeToggle, newTheme);
} }
this.updateHamburgerThemeIcon(); this.updateHamburgerThemeIcon();
this.updatePopoverActiveStates(newTheme, getStorageItem('theme_preset') || 'default');
} }
break; break;
case 'settings': case 'settings':
+8
View File
@@ -1040,7 +1040,15 @@ export class SidebarManager {
<span class="sidebar-hidden-indicator-tooltip">${translate('sidebar.showSidebar')}</span> <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', () => { indicator.addEventListener('click', () => {
setStorageItem(restoreKey, true);
this.toggleHideOnThisPage(); this.toggleHideOnThisPage();
}); });
+93 -1
View File
@@ -234,6 +234,95 @@ function renderLicenseIcons(modelData) {
</div>`; </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 * Display the model modal with the given model data
* @param {Object} model - Model data object * @param {Object} model - Model data object
@@ -264,7 +353,10 @@ export async function showModelModal(model, modelType) {
}; };
const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || ''); const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || '');
const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A'); 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 ? ` 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}"> <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')} <i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
+28 -3
View File
@@ -344,9 +344,14 @@ export class SettingsManager {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') { if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
this.isOpen = settingsModal.style.display === 'block'; this.isOpen = settingsModal.style.display === 'block';
// When modal is opened, update checkbox state from current settings
if (this.isOpen) { if (this.isOpen) {
this.loadSettingsToUI(); this.loadSettingsToUI();
} else {
// Clear sensitive fields on close to prevent browser save-password prompts
const apiKeyInput = document.getElementById('civitaiApiKey');
if (apiKeyInput) apiKeyInput.value = '';
const proxyPasswordInput = document.getElementById('proxyPassword');
if (proxyPasswordInput) proxyPasswordInput.value = '';
} }
} }
}); });
@@ -805,12 +810,14 @@ export class SettingsManager {
// Set card blur amount slider // Set card blur amount slider
const cardBlurAmountInput = document.getElementById('cardBlurAmount'); const cardBlurAmountInput = document.getElementById('cardBlurAmount');
const cardBlurValue = state.global.settings.card_blur_amount ?? 8;
if (cardBlurAmountInput) { 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'); const cardBlurAmountValue = document.getElementById('cardBlurAmountValue');
if (cardBlurAmountValue) { if (cardBlurAmountValue) {
cardBlurAmountValue.textContent = `${state.global.settings.card_blur_amount ?? 8}px`; cardBlurAmountValue.textContent = `${cardBlurValue}px`;
} }
const usePortableCheckbox = document.getElementById('usePortableSettings'); const usePortableCheckbox = document.getElementById('usePortableSettings');
@@ -818,6 +825,11 @@ export class SettingsManager {
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings; usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
} }
const civitaiApiKeyInput = document.getElementById('civitaiApiKey');
if (civitaiApiKeyInput) {
civitaiApiKeyInput.value = state.global.settings.civitai_api_key || '';
}
const civitaiHostSelect = document.getElementById('civitaiHost'); const civitaiHostSelect = document.getElementById('civitaiHost');
if (civitaiHostSelect) { if (civitaiHostSelect) {
civitaiHostSelect.value = state.global.settings.civitai_host || 'civitai.com'; civitaiHostSelect.value = state.global.settings.civitai_host || 'civitai.com';
@@ -1001,6 +1013,12 @@ export class SettingsManager {
this.loadDownloadBackendSettings(); this.loadDownloadBackendSettings();
this.loadProxySettings(); this.loadProxySettings();
// Set license icon style
const useNewLicenseIconsCheckbox = document.getElementById('useNewLicenseIcons');
if (useNewLicenseIconsCheckbox) {
useNewLicenseIconsCheckbox.checked = state.global.settings.use_new_license_icons !== false;
}
} }
loadDownloadBackendSettings() { loadDownloadBackendSettings() {
@@ -2070,6 +2088,9 @@ export class SettingsManager {
displayEl.textContent = `${value}px`; 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'); showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
} catch (error) { } catch (error) {
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error'); showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
@@ -2942,6 +2963,10 @@ export class SettingsManager {
const showVersionOnCard = state.global.settings.show_version_on_card !== false; const showVersionOnCard = state.global.settings.show_version_on_card !== false;
document.body.classList.toggle('hide-card-version', !showVersionOnCard); 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);
} }
} }
+1
View File
@@ -52,6 +52,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
backup_auto_enabled: true, backup_auto_enabled: true,
backup_retention_count: 5, backup_retention_count: 5,
strip_lora_on_copy: false, strip_lora_on_copy: false,
use_new_license_icons: true,
}); });
export function createDefaultSettings() { export function createDefaultSettings() {
+65
View File
@@ -240,6 +240,9 @@ export class StatisticsManager {
// Storage efficiency chart // Storage efficiency chart
this.createStorageEfficiencyChart(); this.createStorageEfficiencyChart();
// Model types chart (Collection tab)
this.createModelTypesChart();
} }
createCollectionPieChart() { 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() { async initializeLists() {
const listTypes = [ const listTypes = [
{ type: 'lora', containerId: 'topLorasList' }, { type: 'lora', containerId: 'topLorasList' },
+38 -18
View File
@@ -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() { export function initTheme() {
const savedTheme = getStorageItem('theme') || 'auto'; const savedTheme = getStorageItem('theme') || 'auto';
// Migrate deprecated presets
let savedPreset = getStorageItem('theme_preset');
if (savedPreset === 'gruvbox') {
savedPreset = 'midnight';
setStorageItem('theme_preset', 'midnight');
}
applyTheme(savedTheme); applyTheme(savedTheme);
applyPreset(savedPreset || 'default');
// Update theme when system preference changes (for 'auto' mode)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const currentTheme = getStorageItem('theme') || 'auto'; const currentTheme = getStorageItem('theme') || 'auto';
if (currentTheme === 'auto') { if (currentTheme === 'auto') {
@@ -212,34 +223,44 @@ export function initTheme() {
export function toggleTheme() { export function toggleTheme() {
const currentTheme = getStorageItem('theme') || 'auto'; const currentTheme = getStorageItem('theme') || 'auto';
let newTheme; const currentIndex = CYCLE_ORDER.indexOf(currentTheme);
const nextIndex = (currentIndex + 1) % CYCLE_ORDER.length;
if (currentTheme === 'light') { const newTheme = CYCLE_ORDER[nextIndex];
newTheme = 'dark';
} else {
newTheme = 'light';
}
setStorageItem('theme', newTheme); setStorageItem('theme', newTheme);
applyTheme(newTheme); applyTheme(newTheme);
// Force a repaint to ensure theme changes are applied immediately
document.body.style.display = 'none'; document.body.style.display = 'none';
document.body.offsetHeight; // Trigger a reflow document.body.offsetHeight;
document.body.style.display = ''; document.body.style.display = '';
return newTheme; 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) { function applyTheme(theme) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const htmlElement = document.documentElement; const htmlElement = document.documentElement;
// Remove any existing theme attributes
htmlElement.removeAttribute('data-theme'); htmlElement.removeAttribute('data-theme');
// Apply the appropriate theme
if (theme === 'dark' || (theme === 'auto' && prefersDark)) { if (theme === 'dark' || (theme === 'auto' && prefersDark)) {
htmlElement.setAttribute('data-theme', 'dark'); htmlElement.setAttribute('data-theme', 'dark');
document.body.dataset.theme = 'dark'; document.body.dataset.theme = 'dark';
@@ -248,19 +269,18 @@ function applyTheme(theme) {
document.body.dataset.theme = 'light'; document.body.dataset.theme = 'light';
} }
// Update the theme-toggle icon state
updateThemeToggleIcons(theme); updateThemeToggleIcons(theme);
} }
// New function to update theme toggle icons function applyPreset(preset) {
document.documentElement.setAttribute('data-theme-preset', preset);
}
function updateThemeToggleIcons(theme) { function updateThemeToggleIcons(theme) {
const themeToggle = document.querySelector('.theme-toggle'); const themeToggle = document.querySelector('.theme-toggle');
if (!themeToggle) return; if (!themeToggle) return;
// Remove any existing active classes
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto'); themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
// Add the appropriate class based on current theme
themeToggle.classList.add(`theme-${theme}`); themeToggle.classList.add(`theme-${theme}`);
} }
+8 -4
View File
@@ -46,16 +46,20 @@
</script> </script>
<script> <script>
(function() { (function() {
// Apply theme immediately based on stored preference var STORAGE_PREFIX = 'lora_manager_';
const STORAGE_PREFIX = 'lora_manager_'; var savedTheme = localStorage.getItem(STORAGE_PREFIX + 'theme') || 'auto';
const savedTheme = localStorage.getItem(STORAGE_PREFIX + 'theme') || 'auto'; var savedPreset = localStorage.getItem(STORAGE_PREFIX + 'theme_preset') || 'default';
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme === 'dark' || (savedTheme === 'auto' && prefersDark)) { if (savedTheme === 'dark' || (savedTheme === 'auto' && prefersDark)) {
document.documentElement.setAttribute('data-theme', 'dark'); document.documentElement.setAttribute('data-theme', 'dark');
} else { } else {
document.documentElement.setAttribute('data-theme', 'light'); document.documentElement.setAttribute('data-theme', 'light');
} }
if (savedPreset && savedPreset !== 'default') {
document.documentElement.setAttribute('data-theme-preset', savedPreset);
}
})(); })();
</script> </script>
{% block head_scripts %}{% endblock %} {% block head_scripts %}{% endblock %}
-24
View File
@@ -100,30 +100,6 @@
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span> <span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
</button> </button>
</div> </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> </div>
</div> </div>
+50
View File
@@ -120,6 +120,56 @@
</div> </div>
</header> </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 --> <!-- Add search options panel with context-aware options -->
<div id="searchOptionsPanel" class="search-options-panel hidden"> <div id="searchOptionsPanel" class="search-options-panel hidden">
<div class="options-header"> <div class="options-header">
@@ -103,7 +103,6 @@
<input type="password" <input type="password"
id="civitaiApiKey" id="civitaiApiKey"
placeholder="{{ t('settings.civitaiApiKeyPlaceholder') }}" placeholder="{{ t('settings.civitaiApiKeyPlaceholder') }}"
value="{{ settings.get('civitai_api_key', '') }}"
autocomplete="new-password" autocomplete="new-password"
onblur="settingsManager.saveInputSetting('civitaiApiKey', 'civitai_api_key')" onblur="settingsManager.saveInputSetting('civitaiApiKey', 'civitai_api_key')"
onkeydown="if(event.key === 'Enter') { this.blur(); }" /> onkeydown="if(event.key === 'Enter') { this.blur(); }" />
@@ -549,7 +548,7 @@
</div> </div>
<div class="setting-control range-control"> <div class="setting-control range-control">
<input type="range" id="cardBlurAmount" min="0" max="20" value="8" step="1" <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')"> onchange="settingsManager.saveRangeSetting('cardBlurAmount', 'cardBlurAmountValue', 'card_blur_amount')">
<span id="cardBlurAmountValue" class="range-value">8px</span> <span id="cardBlurAmountValue" class="range-value">8px</span>
</div> </div>
@@ -592,6 +591,30 @@
</div> </div>
</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 --> <!-- Miscellaneous -->
<div class="settings-subsection"> <div class="settings-subsection">
<div class="settings-subsection-header"> <div class="settings-subsection-header">
-24
View File
@@ -137,30 +137,6 @@
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span> <span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
</button> </button>
</div> </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> </div>
@@ -101,14 +101,19 @@ vi.mock(API_FACTORY, () => ({
describe('Model modal license rendering', () => { describe('Model modal license rendering', () => {
let getModelApiClient; let getModelApiClient;
let state;
beforeEach(async () => { beforeEach(async () => {
document.body.innerHTML = ''; document.body.innerHTML = '';
({ getModelApiClient } = await import(API_FACTORY)); ({ getModelApiClient } = await import(API_FACTORY));
getModelApiClient.mockReset(); 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); const fetchModelMetadata = vi.fn().mockResolvedValue(null);
getModelApiClient.mockReturnValue({ getModelApiClient.mockReturnValue({
fetchModelMetadata, fetchModelMetadata,
+34
View File
@@ -73,6 +73,40 @@
mask: var(--license-icon-image) center/contain no-repeat; 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 { .lm-loras-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+144 -7
View File
@@ -12,6 +12,8 @@ const LICENSE_FLAG_BITS = {
allowRelicense: 1 << 6, allowRelicense: 1 << 6,
}; };
// ── Set 1 (classic) icon definitions ──
const LICENSE_ICON_COPY = { const LICENSE_ICON_COPY = {
credit: "Creator credit required", credit: "Creator credit required",
image: "No selling generated content", 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 }, { 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) { function parseLicenseFlags(value) {
if (typeof value === "number") { if (typeof value === "number") {
return Number.isFinite(value) ? value : null; return Number.isFinite(value) ? value : null;
@@ -78,6 +125,81 @@ function createLicenseIconElement({ icon, label }) {
return element; 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. * Lightweight preview tooltip that can display images or videos for different model types.
*/ */
@@ -101,6 +223,10 @@ export class PreviewTooltip {
ensureLmStyles(); 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 = document.createElement("div");
this.element.className = "lm-tooltip"; this.element.className = "lm-tooltip";
document.body.appendChild(this.element); document.body.appendChild(this.element);
@@ -135,6 +261,7 @@ export class PreviewTooltip {
previewUrl: data.preview_url, previewUrl: data.preview_url,
displayName: data.display_name ?? modelName, displayName: data.display_name ?? modelName,
licenseFlags: parseLicenseFlags(data.license_flags), 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) { if (!previewUrl) {
throw new Error("No preview URL available"); throw new Error("No preview URL available");
} }
@@ -161,6 +288,7 @@ export class PreviewTooltip {
? displayName ? displayName
: this.displayNameFormatter(modelName), : this.displayNameFormatter(modelName),
licenseFlags: parseLicenseFlags(licenseFlags), licenseFlags: parseLicenseFlags(licenseFlags),
useNewLicenseIcons,
}; };
} }
@@ -182,7 +310,7 @@ export class PreviewTooltip {
} }
this.currentModelName = modelName; this.currentModelName = modelName;
const { previewUrl, displayName, licenseFlags } = await this.resolvePreviewData( const { previewUrl, displayName, licenseFlags, useNewLicenseIcons } = await this.resolvePreviewData(
modelName modelName
); );
@@ -211,7 +339,7 @@ export class PreviewTooltip {
nameLabel.className = "lm-tooltip__label"; nameLabel.className = "lm-tooltip__label";
mediaContainer.appendChild(mediaElement); mediaContainer.appendChild(mediaElement);
this.renderLicenseOverlay(mediaContainer, licenseFlags); this.renderLicenseOverlay(mediaContainer, licenseFlags, useNewLicenseIcons);
mediaContainer.appendChild(nameLabel); mediaContainer.appendChild(nameLabel);
this.element.appendChild(mediaContainer); this.element.appendChild(mediaContainer);
@@ -293,16 +421,25 @@ export class PreviewTooltip {
} }
} }
renderLicenseOverlay(container, licenseFlags) { renderLicenseOverlay(container, licenseFlags, useNewLicenseIcons) {
const icons = buildLicenseIconData(licenseFlags); const useNew = useNewLicenseIcons !== undefined ? useNewLicenseIcons : getUseNewLicenseIcons();
const icons = useNew
? buildNewLicenseIconData(licenseFlags)
: buildLicenseIconData(licenseFlags);
if (!icons.length) { if (!icons.length) {
return; return;
} }
const overlay = document.createElement("div"); 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) => { icons.forEach((descriptor) => {
overlay.appendChild(createLicenseIconElement(descriptor)); overlay.appendChild(
useNew
? createNewLicenseIconElement(descriptor)
: createLicenseIconElement(descriptor)
);
}); });
container.appendChild(overlay); container.appendChild(overlay);
} }