Compare commits

..

13 Commits

Author SHA1 Message Date
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
38 changed files with 1194 additions and 531 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
+12 -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",
@@ -1424,15 +1435,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...",
+12 -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",
@@ -1424,15 +1435,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...",
+12 -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",
@@ -1424,15 +1435,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...",
+12 -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",
@@ -1424,15 +1435,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...",
+12 -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": "בדוק עדכונים",
@@ -1424,15 +1435,6 @@
"duplicate": "תגית זו כבר קיימת" "duplicate": "תגית זו כבר קיימת"
} }
}, },
"keyboard": {
"navigation": "ניווט במקלדת:",
"shortcuts": {
"pageUp": "גלול עמוד אחד למעלה",
"pageDown": "גלול עמוד אחד למטה",
"home": "קפוץ להתחלה",
"end": "קפוץ לסוף"
}
},
"initialization": { "initialization": {
"title": "מאתחל", "title": "מאתחל",
"message": "מכין את סביבת העבודה שלך...", "message": "מכין את סביבת העבודה שלך...",
+12 -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": "更新確認",
@@ -1424,15 +1435,6 @@
"duplicate": "このタグは既に存在します" "duplicate": "このタグは既に存在します"
} }
}, },
"keyboard": {
"navigation": "キーボードナビゲーション:",
"shortcuts": {
"pageUp": "1ページ上にスクロール",
"pageDown": "1ページ下にスクロール",
"home": "トップにジャンプ",
"end": "ボトムにジャンプ"
}
},
"initialization": { "initialization": {
"title": "初期化中", "title": "初期化中",
"message": "ワークスペースを準備中...", "message": "ワークスペースを準備中...",
+12 -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": "업데이트 확인",
@@ -1424,15 +1435,6 @@
"duplicate": "이 태그는 이미 존재합니다" "duplicate": "이 태그는 이미 존재합니다"
} }
}, },
"keyboard": {
"navigation": "키보드 내비게이션:",
"shortcuts": {
"pageUp": "한 페이지 위로 스크롤",
"pageDown": "한 페이지 아래로 스크롤",
"home": "맨 위로 이동",
"end": "맨 아래로 이동"
}
},
"initialization": { "initialization": {
"title": "초기화 중", "title": "초기화 중",
"message": "작업공간을 준비하고 있습니다...", "message": "작업공간을 준비하고 있습니다...",
+12 -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": "Проверить обновления",
@@ -1424,15 +1435,6 @@
"duplicate": "Этот тег уже существует" "duplicate": "Этот тег уже существует"
} }
}, },
"keyboard": {
"navigation": "Навигация с клавиатуры:",
"shortcuts": {
"pageUp": "Прокрутить на страницу вверх",
"pageDown": "Прокрутить на страницу вниз",
"home": "Перейти к началу",
"end": "Перейти к концу"
}
},
"initialization": { "initialization": {
"title": "Инициализация", "title": "Инициализация",
"message": "Подготовка вашего рабочего пространства...", "message": "Подготовка вашего рабочего пространства...",
+12 -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": "检查更新",
@@ -1424,15 +1435,6 @@
"duplicate": "该标签已存在" "duplicate": "该标签已存在"
} }
}, },
"keyboard": {
"navigation": "键盘导航:",
"shortcuts": {
"pageUp": "向上一页滚动",
"pageDown": "向下一页滚动",
"home": "跳到顶部",
"end": "跳到底部"
}
},
"initialization": { "initialization": {
"title": "初始化", "title": "初始化",
"message": "正在准备你的工作空间...", "message": "正在准备你的工作空间...",
+12 -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": "檢查更新",
@@ -1424,15 +1435,6 @@
"duplicate": "此標籤已存在" "duplicate": "此標籤已存在"
} }
}, },
"keyboard": {
"navigation": "鍵盤導覽:",
"shortcuts": {
"pageUp": "向上捲動一頁",
"pageDown": "向下捲動一頁",
"home": "跳至頂部",
"end": "跳至底部"
}
},
"initialization": { "initialization": {
"title": "初始化", "title": "初始化",
"message": "正在準備您的工作區...", "message": "正在準備您的工作區...",
+34
View File
@@ -1820,6 +1820,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 +2897,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
+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",
+7 -7
View File
@@ -389,7 +389,7 @@
} }
.stat-item.success { .stat-item.success {
border-left: 3px solid #00B87A; border-left: 3px solid var(--color-success);
} }
.stat-item.failed { .stat-item.failed {
@@ -455,7 +455,7 @@
.results-icon { .results-icon {
font-size: 3em; font-size: 3em;
color: #00B87A; color: var(--color-success);
margin-bottom: var(--space-1); margin-bottom: var(--space-1);
} }
@@ -493,7 +493,7 @@
} }
.result-card.success { .result-card.success {
border-left: 3px solid #00B87A; border-left: 3px solid var(--color-success);
} }
.result-card.failed { .result-card.failed {
@@ -582,8 +582,8 @@
} }
.result-item-status.success { .result-item-status.success {
background: oklch(from #00B87A l c h / 0.2); background: color-mix(in oklch, var(--color-success) 20%, transparent);
color: #00B87A; color: var(--color-success);
} }
.result-item-status.failed { .result-item-status.failed {
@@ -661,11 +661,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 {
+197 -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,203 @@
opacity: 0; opacity: 0;
} }
.theme-popover {
display: none;
position: absolute;
top: calc(100% + 8px);
right: -8px;
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: var(--z-dropdown, 200);
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);
}
+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);
} }
+16 -2
View File
@@ -114,8 +114,7 @@
.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: 44px;
@@ -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);
}
+120 -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,126 @@ 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) => {
if (e.target.closest('.theme-popover')) return;
e.stopPropagation();
const isOpen = themePopover.classList.contains('active');
this.closeAllPopovers();
if (!isOpen) {
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');
}
});
}
closeAllPopovers() {
const themePopover = document.getElementById('themePopover');
if (themePopover) {
themePopover.classList.remove('active');
}
}
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 +253,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 +260,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();
}); });
+7 -2
View File
@@ -805,12 +805,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');
@@ -2070,6 +2072,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');
+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>
+49
View File
@@ -72,6 +72,55 @@
<i class="fas fa-moon dark-icon"></i> <i class="fas fa-moon dark-icon"></i>
<i class="fas fa-sun light-icon"></i> <i class="fas fa-sun light-icon"></i>
<i class="fas fa-adjust auto-icon"></i> <i class="fas fa-adjust auto-icon"></i>
<div class="theme-popover" id="themePopover">
<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>
</div> </div>
<div class="settings-toggle" title="{{ t('common.actions.settings') }}"> <div class="settings-toggle" title="{{ t('common.actions.settings') }}">
<i class="fas fa-cog"></i> <i class="fas fa-cog"></i>
@@ -549,7 +549,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>
-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>