Compare commits

..

9 Commits

Author SHA1 Message Date
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
25 changed files with 180 additions and 409 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
-9
View File
@@ -1424,15 +1424,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...",
-9
View File
@@ -1424,15 +1424,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...",
-9
View File
@@ -1424,15 +1424,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...",
-9
View File
@@ -1424,15 +1424,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...",
-9
View File
@@ -1424,15 +1424,6 @@
"duplicate": "תגית זו כבר קיימת" "duplicate": "תגית זו כבר קיימת"
} }
}, },
"keyboard": {
"navigation": "ניווט במקלדת:",
"shortcuts": {
"pageUp": "גלול עמוד אחד למעלה",
"pageDown": "גלול עמוד אחד למטה",
"home": "קפוץ להתחלה",
"end": "קפוץ לסוף"
}
},
"initialization": { "initialization": {
"title": "מאתחל", "title": "מאתחל",
"message": "מכין את סביבת העבודה שלך...", "message": "מכין את סביבת העבודה שלך...",
-9
View File
@@ -1424,15 +1424,6 @@
"duplicate": "このタグは既に存在します" "duplicate": "このタグは既に存在します"
} }
}, },
"keyboard": {
"navigation": "キーボードナビゲーション:",
"shortcuts": {
"pageUp": "1ページ上にスクロール",
"pageDown": "1ページ下にスクロール",
"home": "トップにジャンプ",
"end": "ボトムにジャンプ"
}
},
"initialization": { "initialization": {
"title": "初期化中", "title": "初期化中",
"message": "ワークスペースを準備中...", "message": "ワークスペースを準備中...",
-9
View File
@@ -1424,15 +1424,6 @@
"duplicate": "이 태그는 이미 존재합니다" "duplicate": "이 태그는 이미 존재합니다"
} }
}, },
"keyboard": {
"navigation": "키보드 내비게이션:",
"shortcuts": {
"pageUp": "한 페이지 위로 스크롤",
"pageDown": "한 페이지 아래로 스크롤",
"home": "맨 위로 이동",
"end": "맨 아래로 이동"
}
},
"initialization": { "initialization": {
"title": "초기화 중", "title": "초기화 중",
"message": "작업공간을 준비하고 있습니다...", "message": "작업공간을 준비하고 있습니다...",
-9
View File
@@ -1424,15 +1424,6 @@
"duplicate": "Этот тег уже существует" "duplicate": "Этот тег уже существует"
} }
}, },
"keyboard": {
"navigation": "Навигация с клавиатуры:",
"shortcuts": {
"pageUp": "Прокрутить на страницу вверх",
"pageDown": "Прокрутить на страницу вниз",
"home": "Перейти к началу",
"end": "Перейти к концу"
}
},
"initialization": { "initialization": {
"title": "Инициализация", "title": "Инициализация",
"message": "Подготовка вашего рабочего пространства...", "message": "Подготовка вашего рабочего пространства...",
-9
View File
@@ -1424,15 +1424,6 @@
"duplicate": "该标签已存在" "duplicate": "该标签已存在"
} }
}, },
"keyboard": {
"navigation": "键盘导航:",
"shortcuts": {
"pageUp": "向上一页滚动",
"pageDown": "向下一页滚动",
"home": "跳到顶部",
"end": "跳到底部"
}
},
"initialization": { "initialization": {
"title": "初始化", "title": "初始化",
"message": "正在准备你的工作空间...", "message": "正在准备你的工作空间...",
-9
View File
@@ -1424,15 +1424,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"),
) )
+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",
-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);
} }
+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';
+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');
-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>
@@ -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>