Compare commits

..

8 Commits

23 changed files with 1606 additions and 762 deletions

View File

@@ -34,6 +34,14 @@ Enhance your Civitai browsing experience with our companion browser extension! S
## Release Notes ## Release Notes
### v0.8.22
* **Embeddings Management** - Added Embeddings page for comprehensive embedding model management.
* **Advanced Sorting Options** - Introduced flexible sorting controls, allowing sorting by name, added date, or file size in both ascending and descending order.
* **Custom Download Path Templates & Base Model Mapping** - Implemented UI settings for configuring download path templates and base model path mappings, allowing customized model organization and storage location when downloading models via LM Civitai Extension.
* **LM Civitai Extension Enhancements** - Improved concurrent download performance and stability, with new support for canceling active downloads directly from the extension interface.
* **Update Feature** - Added update functionality, allowing users to update LoRA Manager to the latest release version directly from the LoRA Manager UI.
* **Bulk Operations: Refresh All** - Added bulk refresh functionality, allowing users to update Civitai metadata across multiple LoRAs.
### v0.8.20 ### v0.8.20
* **LM Civitai Extension** - Released [browser extension through Chrome Web Store](https://chromewebstore.google.com/detail/lm-civitai-extension/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) that works seamlessly with LoRA Manager to enhance Civitai browsing experience, showing which models are already in your local library, enabling one-click downloads, and providing queue and parallel download support * **LM Civitai Extension** - Released [browser extension through Chrome Web Store](https://chromewebstore.google.com/detail/lm-civitai-extension/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) that works seamlessly with LoRA Manager to enhance Civitai browsing experience, showing which models are already in your local library, enabling one-click downloads, and providing queue and parallel download support
* **Enhanced Lora Loader** - Added support for nunchaku, improving convenience when working with ComfyUI-nunchaku workflows, plus new template workflows for quick onboarding * **Enhanced Lora Loader** - Added support for nunchaku, improving convenience when working with ComfyUI-nunchaku workflows, plus new template workflows for quick onboarding

View File

@@ -20,6 +20,7 @@ class StatsRoutes:
def __init__(self): def __init__(self):
self.lora_scanner = None self.lora_scanner = None
self.checkpoint_scanner = None self.checkpoint_scanner = None
self.embedding_scanner = None
self.usage_stats = None self.usage_stats = None
self.template_env = jinja2.Environment( self.template_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(config.templates_path), loader=jinja2.FileSystemLoader(config.templates_path),
@@ -30,6 +31,7 @@ class StatsRoutes:
"""Initialize services from ServiceRegistry""" """Initialize services from ServiceRegistry"""
self.lora_scanner = await ServiceRegistry.get_lora_scanner() self.lora_scanner = await ServiceRegistry.get_lora_scanner()
self.checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner() self.checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
self.embedding_scanner = await ServiceRegistry.get_embedding_scanner()
self.usage_stats = UsageStats() self.usage_stats = UsageStats()
async def handle_stats_page(self, request: web.Request) -> web.Response: async def handle_stats_page(self, request: web.Request) -> web.Response:
@@ -49,7 +51,12 @@ class StatsRoutes:
(hasattr(self.checkpoint_scanner, '_is_initializing') and self.checkpoint_scanner._is_initializing) (hasattr(self.checkpoint_scanner, '_is_initializing') and self.checkpoint_scanner._is_initializing)
) )
is_initializing = lora_initializing or checkpoint_initializing embedding_initializing = (
self.embedding_scanner._cache is None or
(hasattr(self.embedding_scanner, 'is_initializing') and self.embedding_scanner.is_initializing())
)
is_initializing = lora_initializing or checkpoint_initializing or embedding_initializing
template = self.template_env.get_template('statistics.html') template = self.template_env.get_template('statistics.html')
rendered = template.render( rendered = template.render(
@@ -85,21 +92,29 @@ class StatsRoutes:
checkpoint_count = len(checkpoint_cache.raw_data) checkpoint_count = len(checkpoint_cache.raw_data)
checkpoint_size = sum(cp.get('size', 0) for cp in checkpoint_cache.raw_data) checkpoint_size = sum(cp.get('size', 0) for cp in checkpoint_cache.raw_data)
# Get Embedding statistics
embedding_cache = await self.embedding_scanner.get_cached_data()
embedding_count = len(embedding_cache.raw_data)
embedding_size = sum(emb.get('size', 0) for emb in embedding_cache.raw_data)
# Get usage statistics # Get usage statistics
usage_data = await self.usage_stats.get_stats() usage_data = await self.usage_stats.get_stats()
return web.json_response({ return web.json_response({
'success': True, 'success': True,
'data': { 'data': {
'total_models': lora_count + checkpoint_count, 'total_models': lora_count + checkpoint_count + embedding_count,
'lora_count': lora_count, 'lora_count': lora_count,
'checkpoint_count': checkpoint_count, 'checkpoint_count': checkpoint_count,
'total_size': lora_size + checkpoint_size, 'embedding_count': embedding_count,
'total_size': lora_size + checkpoint_size + embedding_size,
'lora_size': lora_size, 'lora_size': lora_size,
'checkpoint_size': checkpoint_size, 'checkpoint_size': checkpoint_size,
'embedding_size': embedding_size,
'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', {}))
} }
}) })
@@ -121,14 +136,17 @@ class StatsRoutes:
# Get model data for enrichment # Get model data for enrichment
lora_cache = await self.lora_scanner.get_cached_data() lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data() checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
embedding_cache = await self.embedding_scanner.get_cached_data()
# Create hash to model mapping # Create hash to model mapping
lora_map = {lora['sha256']: lora for lora in lora_cache.raw_data} lora_map = {lora['sha256']: lora for lora in lora_cache.raw_data}
checkpoint_map = {cp['sha256']: cp for cp in checkpoint_cache.raw_data} checkpoint_map = {cp['sha256']: cp for cp in checkpoint_cache.raw_data}
embedding_map = {emb['sha256']: emb for emb in embedding_cache.raw_data}
# Prepare top used models # Prepare top used models
top_loras = self._get_top_used_models(usage_data.get('loras', {}), lora_map, 10) top_loras = self._get_top_used_models(usage_data.get('loras', {}), lora_map, 10)
top_checkpoints = self._get_top_used_models(usage_data.get('checkpoints', {}), checkpoint_map, 10) top_checkpoints = self._get_top_used_models(usage_data.get('checkpoints', {}), checkpoint_map, 10)
top_embeddings = self._get_top_used_models(usage_data.get('embeddings', {}), embedding_map, 10)
# Prepare usage timeline (last 30 days) # Prepare usage timeline (last 30 days)
timeline = self._get_usage_timeline(usage_data, 30) timeline = self._get_usage_timeline(usage_data, 30)
@@ -138,6 +156,7 @@ class StatsRoutes:
'data': { 'data': {
'top_loras': top_loras, 'top_loras': top_loras,
'top_checkpoints': top_checkpoints, 'top_checkpoints': top_checkpoints,
'top_embeddings': top_embeddings,
'usage_timeline': timeline, 'usage_timeline': timeline,
'total_executions': usage_data.get('total_executions', 0) 'total_executions': usage_data.get('total_executions', 0)
} }
@@ -158,16 +177,19 @@ class StatsRoutes:
# Get model data # Get model data
lora_cache = await self.lora_scanner.get_cached_data() lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data() checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
embedding_cache = await self.embedding_scanner.get_cached_data()
# Count by base model # Count by base model
lora_base_models = Counter(lora.get('base_model', 'Unknown') for lora in lora_cache.raw_data) lora_base_models = Counter(lora.get('base_model', 'Unknown') for lora in lora_cache.raw_data)
checkpoint_base_models = Counter(cp.get('base_model', 'Unknown') for cp in checkpoint_cache.raw_data) checkpoint_base_models = Counter(cp.get('base_model', 'Unknown') for cp in checkpoint_cache.raw_data)
embedding_base_models = Counter(emb.get('base_model', 'Unknown') for emb in embedding_cache.raw_data)
return web.json_response({ return web.json_response({
'success': True, 'success': True,
'data': { 'data': {
'loras': dict(lora_base_models), 'loras': dict(lora_base_models),
'checkpoints': dict(checkpoint_base_models) 'checkpoints': dict(checkpoint_base_models),
'embeddings': dict(embedding_base_models)
} }
}) })
@@ -186,6 +208,7 @@ class StatsRoutes:
# Get model data # Get model data
lora_cache = await self.lora_scanner.get_cached_data() lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data() checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
embedding_cache = await self.embedding_scanner.get_cached_data()
# Count tag frequencies # Count tag frequencies
all_tags = [] all_tags = []
@@ -193,6 +216,8 @@ class StatsRoutes:
all_tags.extend(lora.get('tags', [])) all_tags.extend(lora.get('tags', []))
for cp in checkpoint_cache.raw_data: for cp in checkpoint_cache.raw_data:
all_tags.extend(cp.get('tags', [])) all_tags.extend(cp.get('tags', []))
for emb in embedding_cache.raw_data:
all_tags.extend(emb.get('tags', []))
tag_counts = Counter(all_tags) tag_counts = Counter(all_tags)
@@ -225,6 +250,7 @@ class StatsRoutes:
# Get model data # Get model data
lora_cache = await self.lora_scanner.get_cached_data() lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data() checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
embedding_cache = await self.embedding_scanner.get_cached_data()
# Create models with usage data # Create models with usage data
lora_storage = [] lora_storage = []
@@ -255,15 +281,31 @@ class StatsRoutes:
'base_model': cp.get('base_model', 'Unknown') 'base_model': cp.get('base_model', 'Unknown')
}) })
embedding_storage = []
for emb in embedding_cache.raw_data:
usage_count = 0
if emb['sha256'] in usage_data.get('embeddings', {}):
usage_count = usage_data['embeddings'][emb['sha256']].get('total', 0)
embedding_storage.append({
'name': emb['model_name'],
'size': emb.get('size', 0),
'usage_count': usage_count,
'folder': emb.get('folder', ''),
'base_model': emb.get('base_model', 'Unknown')
})
# Sort by size # Sort by size
lora_storage.sort(key=lambda x: x['size'], reverse=True) lora_storage.sort(key=lambda x: x['size'], reverse=True)
checkpoint_storage.sort(key=lambda x: x['size'], reverse=True) checkpoint_storage.sort(key=lambda x: x['size'], reverse=True)
embedding_storage.sort(key=lambda x: x['size'], reverse=True)
return web.json_response({ return web.json_response({
'success': True, 'success': True,
'data': { 'data': {
'loras': lora_storage[:20], # Top 20 by size 'loras': lora_storage[:20], # Top 20 by size
'checkpoints': checkpoint_storage[:20] 'checkpoints': checkpoint_storage[:20],
'embeddings': embedding_storage[:20]
} }
}) })
@@ -285,15 +327,18 @@ class StatsRoutes:
# Get model data # Get model data
lora_cache = await self.lora_scanner.get_cached_data() lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data() checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
embedding_cache = await self.embedding_scanner.get_cached_data()
insights = [] insights = []
# Calculate unused models # Calculate unused models
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', {}))
total_loras = len(lora_cache.raw_data) total_loras = len(lora_cache.raw_data)
total_checkpoints = len(checkpoint_cache.raw_data) total_checkpoints = len(checkpoint_cache.raw_data)
total_embeddings = len(embedding_cache.raw_data)
if total_loras > 0: if total_loras > 0:
unused_lora_percent = (unused_loras / total_loras) * 100 unused_lora_percent = (unused_loras / total_loras) * 100
@@ -315,9 +360,20 @@ class StatsRoutes:
'suggestion': 'Review and consider removing checkpoints you no longer need.' 'suggestion': 'Review and consider removing checkpoints you no longer need.'
}) })
if total_embeddings > 0:
unused_embedding_percent = (unused_embeddings / total_embeddings) * 100
if unused_embedding_percent > 50:
insights.append({
'type': 'warning',
'title': 'High Number of Unused Embeddings',
'description': f'{unused_embedding_percent:.1f}% of your embeddings ({unused_embeddings}/{total_embeddings}) have never been used.',
'suggestion': 'Consider organizing or archiving unused embeddings to optimize your collection.'
})
# Storage insights # Storage insights
total_size = sum(lora.get('size', 0) for lora in lora_cache.raw_data) + \ total_size = sum(lora.get('size', 0) for lora in lora_cache.raw_data) + \
sum(cp.get('size', 0) for cp in checkpoint_cache.raw_data) sum(cp.get('size', 0) for cp in checkpoint_cache.raw_data) + \
sum(emb.get('size', 0) for emb in embedding_cache.raw_data)
if total_size > 100 * 1024 * 1024 * 1024: # 100GB if total_size > 100 * 1024 * 1024 * 1024: # 100GB
insights.append({ insights.append({
@@ -390,6 +446,7 @@ class StatsRoutes:
lora_usage = 0 lora_usage = 0
checkpoint_usage = 0 checkpoint_usage = 0
embedding_usage = 0
# Count usage for this date # Count usage for this date
for model_usage in usage_data.get('loras', {}).values(): for model_usage in usage_data.get('loras', {}).values():
@@ -400,11 +457,16 @@ class StatsRoutes:
if isinstance(model_usage, dict) and 'history' in model_usage: if isinstance(model_usage, dict) and 'history' in model_usage:
checkpoint_usage += model_usage['history'].get(date_str, 0) checkpoint_usage += model_usage['history'].get(date_str, 0)
for model_usage in usage_data.get('embeddings', {}).values():
if isinstance(model_usage, dict) and 'history' in model_usage:
embedding_usage += model_usage['history'].get(date_str, 0)
timeline.append({ timeline.append({
'date': date_str, 'date': date_str,
'lora_usage': lora_usage, 'lora_usage': lora_usage,
'checkpoint_usage': checkpoint_usage, 'checkpoint_usage': checkpoint_usage,
'total_usage': lora_usage + checkpoint_usage 'embedding_usage': embedding_usage,
'total_usage': lora_usage + checkpoint_usage + embedding_usage
}) })
return list(reversed(timeline)) # Oldest to newest return list(reversed(timeline)) # Oldest to newest

View File

@@ -4,6 +4,9 @@ import aiohttp
import logging import logging
import toml import toml
import git import git
import zipfile
import shutil
import tempfile
from datetime import datetime from datetime import datetime
from aiohttp import web from aiohttp import web
from typing import Dict, List from typing import Dict, List
@@ -101,34 +104,36 @@ class UpdateRoutes:
@staticmethod @staticmethod
async def perform_update(request): async def perform_update(request):
""" """
Perform Git-based update to latest release tag or main branch Perform Git-based update to latest release tag or main branch.
If .git is missing, fallback to ZIP download.
""" """
try: try:
# Parse request body
body = await request.json() if request.has_body else {} body = await request.json() if request.has_body else {}
nightly = body.get('nightly', False) nightly = body.get('nightly', False)
# Get current plugin directory
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
plugin_root = os.path.dirname(os.path.dirname(current_dir)) plugin_root = os.path.dirname(os.path.dirname(current_dir))
# Backup settings.json if it exists
settings_path = os.path.join(plugin_root, 'settings.json') settings_path = os.path.join(plugin_root, 'settings.json')
settings_backup = None settings_backup = None
if os.path.exists(settings_path): if os.path.exists(settings_path):
with open(settings_path, 'r', encoding='utf-8') as f: with open(settings_path, 'r', encoding='utf-8') as f:
settings_backup = f.read() settings_backup = f.read()
logger.info("Backed up settings.json") logger.info("Backed up settings.json")
# Perform Git update git_folder = os.path.join(plugin_root, '.git')
success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly) if os.path.exists(git_folder):
# Git update
# Restore settings.json if we backed it up success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly)
else:
# Fallback: Download ZIP and replace files
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
if settings_backup and success: if settings_backup and success:
with open(settings_path, 'w', encoding='utf-8') as f: with open(settings_path, 'w', encoding='utf-8') as f:
f.write(settings_backup) f.write(settings_backup)
logger.info("Restored settings.json") logger.info("Restored settings.json")
if success: if success:
return web.json_response({ return web.json_response({
'success': True, 'success': True,
@@ -138,15 +143,86 @@ class UpdateRoutes:
else: else:
return web.json_response({ return web.json_response({
'success': False, 'success': False,
'error': 'Failed to complete Git update' 'error': 'Failed to complete update'
}) })
except Exception as e: except Exception as e:
logger.error(f"Failed to perform update: {e}", exc_info=True) logger.error(f"Failed to perform update: {e}", exc_info=True)
return web.json_response({ return web.json_response({
'success': False, 'success': False,
'error': str(e) 'error': str(e)
}) })
@staticmethod
async def _download_and_replace_zip(plugin_root: str) -> tuple[bool, str]:
"""
Download latest release ZIP from GitHub and replace plugin files.
Skips settings.json.
"""
repo_owner = "willmiao"
repo_name = "ComfyUI-Lora-Manager"
github_api = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
try:
async with aiohttp.ClientSession() as session:
async with session.get(github_api) as resp:
if resp.status != 200:
logger.error(f"Failed to fetch release info: {resp.status}")
return False, ""
data = await resp.json()
zip_url = data.get("zipball_url")
version = data.get("tag_name", "unknown")
# Download ZIP
async with session.get(zip_url) as zip_resp:
if zip_resp.status != 200:
logger.error(f"Failed to download ZIP: {zip_resp.status}")
return False, ""
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp_zip:
tmp_zip.write(await zip_resp.read())
zip_path = tmp_zip.name
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json'])
# Extract ZIP to temp dir
with tempfile.TemporaryDirectory() as tmp_dir:
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(tmp_dir)
# Find extracted folder (GitHub ZIP contains a root folder)
extracted_root = next(os.scandir(tmp_dir)).path
# Copy files, skipping settings.json
for item in os.listdir(extracted_root):
src = os.path.join(extracted_root, item)
dst = os.path.join(plugin_root, item)
if os.path.isdir(src):
# Remove old folder, then copy
if os.path.exists(dst):
shutil.rmtree(dst)
shutil.copytree(src, dst, ignore=shutil.ignore_patterns('settings.json'))
else:
if item == 'settings.json':
continue
shutil.copy2(src, dst)
os.remove(zip_path)
logger.info(f"Updated plugin via ZIP to {version}")
return True, version
except Exception as e:
logger.error(f"ZIP update failed: {e}", exc_info=True)
return False, ""
def _clean_plugin_folder(plugin_root, skip_files=None):
skip_files = skip_files or []
for item in os.listdir(plugin_root):
if item in skip_files:
continue
path = os.path.join(plugin_root, item)
if os.path.isdir(path):
shutil.rmtree(path)
else:
os.remove(path)
@staticmethod @staticmethod
async def _get_nightly_version() -> tuple[str, List[str]]: async def _get_nightly_version() -> tuple[str, List[str]]:

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 = "0.8.21" version = "0.8.22"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

@@ -0,0 +1,245 @@
/* Banner Container */
.banner-container {
position: relative;
width: 100%;
z-index: calc(var(--z-header) - 1);
border-bottom: 1px solid var(--border-color);
background: var(--card-bg);
margin-bottom: var(--space-2);
}
/* Individual Banner */
.banner-item {
position: relative;
padding: var(--space-2) var(--space-3);
background: linear-gradient(135deg,
oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05),
oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.02)
);
border-left: 4px solid var(--lora-accent);
animation: banner-slide-down 0.3s ease-in-out;
}
/* Banner Content Layout */
.banner-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
max-width: 1400px;
margin: 0 auto;
}
/* Banner Text Section */
.banner-text {
flex: 1;
min-width: 0;
}
.banner-title {
margin: 0 0 4px 0;
font-size: 1.1em;
font-weight: 600;
color: var(--text-color);
line-height: 1.3;
}
.banner-description {
margin: 0;
font-size: 0.9em;
color: var(--text-muted);
line-height: 1.4;
}
/* Banner Actions */
.banner-actions {
display: flex;
align-items: center;
gap: var(--space-1);
flex-shrink: 0;
}
.banner-action {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: var(--border-radius-xs);
text-decoration: none;
font-size: 0.85em;
font-weight: 500;
transition: all 0.2s ease;
white-space: nowrap;
border: 1px solid transparent;
}
.banner-action i {
font-size: 0.9em;
}
/* Primary Action Button */
.banner-action-primary {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
.banner-action-primary:hover {
background: oklch(calc(var(--lora-accent-l) - 5%) var(--lora-accent-c) var(--lora-accent-h));
transform: translateY(-1px);
box-shadow: 0 3px 6px oklch(var(--lora-accent) / 0.3);
}
/* Secondary Action Button */
.banner-action-secondary {
background: var(--card-bg);
color: var(--text-color);
border-color: var(--border-color);
}
.banner-action-secondary:hover {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Tertiary Action Button */
.banner-action-tertiary {
background: transparent;
color: var(--lora-accent);
border-color: var(--lora-accent);
}
.banner-action-tertiary:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-1px);
}
/* Dismiss Button */
.banner-dismiss {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
font-size: 0.8em;
}
.banner-dismiss:hover {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
transform: scale(1.1);
}
/* Animations */
@keyframes banner-slide-down {
from {
opacity: 0;
transform: translateY(-100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes banner-slide-up {
from {
opacity: 1;
transform: translateY(0);
max-height: 200px;
}
to {
opacity: 0;
transform: translateY(-20px);
max-height: 0;
padding-top: 0;
padding-bottom: 0;
}
}
/* Responsive Design */
@media (max-width: 768px) {
.banner-content {
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
}
.banner-actions {
width: 100%;
flex-wrap: wrap;
justify-content: flex-start;
}
.banner-action {
flex: 1;
min-width: 0;
justify-content: center;
}
.banner-dismiss {
top: 6px;
right: 6px;
}
.banner-item {
padding: var(--space-2);
}
.banner-title {
font-size: 1em;
}
.banner-description {
font-size: 0.85em;
}
}
@media (max-width: 480px) {
.banner-actions {
flex-direction: column;
width: 100%;
}
.banner-action {
width: 100%;
justify-content: center;
}
.banner-content {
gap: var(--space-1);
}
}
/* Dark theme adjustments */
[data-theme="dark"] .banner-item {
background: linear-gradient(135deg,
oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08),
oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.03)
);
}
/* Prevent text selection */
.banner-item,
.banner-title,
.banner-description,
.banner-action,
.banner-dismiss {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}

View File

@@ -123,6 +123,42 @@
} }
} }
/* 修改 back-to-top 按钮样式,使其固定在 modal 内部 */
.modal-content .back-to-top {
position: sticky; /* 改用 sticky 定位 */
float: right; /* 使用 float 确保按钮在右侧 */
bottom: 20px; /* 距离底部的距离 */
margin-right: 20px; /* 右侧间距 */
margin-top: -56px; /* 负边距确保不占用额外空间 */
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
visibility: hidden;
transform: translateY(10px);
transition: all 0.3s ease;
z-index: 10;
}
.modal-content .back-to-top.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.modal-content .back-to-top:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
/* File name copy styles */ /* File name copy styles */
.file-name-wrapper { .file-name-wrapper {
display: flex; display: flex;

View File

@@ -4,254 +4,31 @@
margin-top: var(--space-4); margin-top: var(--space-4);
} }
/* Main showcase container */ .carousel {
.showcase-container { transition: max-height 0.3s ease-in-out;
display: flex;
height: 750px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
overflow: hidden; overflow: hidden;
background: var(--lora-surface);
} }
.showcase-container.empty { .carousel.collapsed {
height: 400px; max-height: 0;
} }
/* Thumbnail Sidebar */ .carousel-container {
.thumbnail-sidebar {
width: 200px;
background: var(--bg-color);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.thumbnail-grid {
flex: 1;
overflow-y: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer 10+ */
padding: var(--space-2);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-2); gap: var(--space-2);
} }
.thumbnail-grid::-webkit-scrollbar {
display: none; /* WebKit */
}
.thumbnail-item {
position: relative;
aspect-ratio: 1;
border-radius: var(--border-radius-xs);
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s ease;
background: var(--lora-surface);
}
.thumbnail-item:hover {
border-color: var(--lora-accent);
transform: scale(1.02);
}
.thumbnail-item.active {
border-color: var(--lora-accent);
box-shadow: 0 0 0 1px var(--lora-accent);
}
.thumbnail-media {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumbnail-media.blurred {
filter: blur(8px);
}
.video-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7em;
pointer-events: none;
}
.thumbnail-nsfw-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.2em;
}
/* Import Section */
.import-section {
padding: var(--space-2);
border-top: 1px solid var(--border-color);
background: var(--bg-color);
}
.select-files-btn {
width: 100%;
background: var(--lora-accent);
color: var(--lora-text);
border: none;
border-radius: var(--border-radius-xs);
padding: var(--space-2);
cursor: pointer;
font-size: 0.9em;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s;
margin-bottom: var(--space-2);
}
.select-files-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.import-drop-zone {
border: 2px dashed var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-2);
text-align: center;
transition: all 0.3s ease;
background: var(--lora-surface);
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.import-drop-zone.highlight {
border-color: var(--lora-accent);
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
}
.drop-zone-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
color: var(--text-color);
opacity: 0.6;
font-size: 0.8em;
}
.drop-zone-content i {
font-size: 1.2em;
margin-bottom: 2px;
}
/* Main Display Area */
.main-display-area {
flex: 1;
position: relative;
background: var(--card-bg);
overflow: hidden;
}
.main-display-area.empty {
display: flex;
align-items: center;
justify-content: center;
}
.empty-state {
text-align: center;
color: var(--text-color);
opacity: 0.6;
}
.empty-state i {
font-size: 3em;
margin-bottom: var(--space-2);
opacity: 0.5;
}
.empty-state h3 {
margin: 0 0 var(--space-1);
font-weight: 500;
}
.empty-state p {
margin: 0;
font-size: 0.9em;
}
.navigation-controls {
position: absolute;
top: var(--space-2);
right: var(--space-2);
display: flex;
gap: 6px;
z-index: 10;
}
.nav-btn {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
opacity: 0.8;
}
.nav-btn:hover {
opacity: 1;
transform: translateY(-1px);
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.15);
}
.nav-btn.info-btn.active {
background: var(--lora-accent);
color: var(--lora-text);
border-color: var(--lora-accent);
}
.main-media-container {
position: relative;
width: 100%;
height: 100%;
}
.media-wrapper { .media-wrapper {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%;
background: var(--lora-surface); background: var(--lora-surface);
overflow: hidden; margin-bottom: var(--space-2);
overflow: hidden; /* Ensure metadata panel is contained */
}
.media-wrapper:last-child {
margin-bottom: 0;
} }
.media-wrapper img, .media-wrapper img,
@@ -264,11 +41,50 @@
object-fit: contain; object-fit: contain;
} }
/* Media Controls for main display */ .no-examples {
text-align: center;
padding: var(--space-3);
color: var(--text-color);
opacity: 0.7;
}
/* Adjust the media wrapper for tab system */
#showcase-tab .carousel-container {
margin-top: var(--space-2);
}
/* Add styles for blurred showcase content */
.nsfw-media-wrapper {
position: relative;
}
.media-wrapper img.blurred,
.media-wrapper video.blurred {
filter: blur(25px);
}
.media-wrapper .nsfw-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
pointer-events: none;
}
/* Position the toggle button at the top left of showcase media */
.showcase-toggle-btn {
position: absolute;
z-index: 3;
}
/* Add styles for showcase media controls */
.media-controls { .media-controls {
position: absolute; position: absolute;
top: var(--space-2);
left: var(--space-2);
display: flex; display: flex;
gap: 6px; gap: 6px;
z-index: 4; z-index: 4;
@@ -278,15 +94,15 @@
pointer-events: none; pointer-events: none;
} }
.media-wrapper:hover .media-controls { .media-controls.visible {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
pointer-events: auto; pointer-events: auto;
} }
.media-control-btn { .media-control-btn {
width: 32px; width: 28px;
height: 32px; height: 28px;
border-radius: 50%; border-radius: 50%;
background: var(--bg-color); background: var(--bg-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -319,11 +135,13 @@
border-color: var(--lora-error); border-color: var(--lora-error);
} }
/* Disabled state for delete button */
.media-control-btn.example-delete-btn.disabled { .media-control-btn.example-delete-btn.disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
/* Two-step confirmation for delete button */
.media-control-btn.example-delete-btn .confirm-icon { .media-control-btn.example-delete-btn .confirm-icon {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -354,29 +172,16 @@
border-color: var(--lora-error); border-color: var(--lora-error);
} }
/* Toggle blur button for main display */ @keyframes pulse {
.showcase-toggle-btn { 0% {
position: absolute; box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.7);
top: calc(var(--space-2) + 44px); }
left: var(--space-2); 70% {
z-index: 3; box-shadow: 0 0 0 5px rgba(220, 53, 69, 0);
width: 32px; }
height: 32px; 100% {
border-radius: 50%; box-shadow: 0 0 0 0 rgba(220, 53, 69, 0);
background: var(--bg-color); }
border: 1px solid var(--border-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
opacity: 0;
}
.media-wrapper:hover .showcase-toggle-btn {
opacity: 1;
} }
/* Image Metadata Panel Styles */ /* Image Metadata Panel Styles */
@@ -390,20 +195,22 @@
padding: var(--space-2); padding: var(--space-2);
transform: translateY(100%); transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.25s ease; transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.25s ease;
z-index: 15; z-index: 5;
max-height: 50%; max-height: 50%; /* Reduced to take less space */
overflow-y: auto; overflow-y: auto;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
.image-metadata-panel.visible { /* Show metadata panel only when the 'visible' class is added */
.media-wrapper .image-metadata-panel.visible {
transform: translateY(0); transform: translateY(0);
opacity: 0.98; opacity: 0.98;
pointer-events: auto; pointer-events: auto;
} }
/* Adjust to dark theme */
[data-theme="dark"] .image-metadata-panel { [data-theme="dark"] .image-metadata-panel {
background: var(--card-bg); background: var(--card-bg);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
@@ -415,6 +222,7 @@
gap: 10px; gap: 10px;
} }
/* Styling for parameters tags */
.params-tags { .params-tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -447,6 +255,7 @@
color: var(--lora-accent); color: var(--lora-accent);
} }
/* Special styling for prompt row */
.metadata-row.prompt-row { .metadata-row.prompt-row {
flex-direction: column; flex-direction: column;
padding-top: 0; padding-top: 0;
@@ -472,7 +281,7 @@
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
padding: 6px 30px 6px 8px; padding: 6px 30px 6px 8px;
margin-top: 2px; margin-top: 2px;
max-height: 80px; max-height: 80px; /* Reduced from 120px */
overflow-y: auto; overflow-y: auto;
word-break: break-word; word-break: break-word;
width: 100%; width: 100%;
@@ -504,6 +313,27 @@
color: var(--lora-accent); color: var(--lora-accent);
} }
/* Scrollbar styling for metadata panel */
.image-metadata-panel::-webkit-scrollbar {
width: 6px;
}
.image-metadata-panel::-webkit-scrollbar-track {
background: transparent;
}
.image-metadata-panel::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 3px;
}
/* For Firefox */
.image-metadata-panel {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
/* No metadata message styling */
.no-metadata-message { .no-metadata-message {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -522,66 +352,31 @@
opacity: 0.8; opacity: 0.8;
} }
/* Scrollbar styling for metadata panel */ /* Scroll Indicator */
.image-metadata-panel::-webkit-scrollbar { .scroll-indicator {
width: 6px; cursor: pointer;
} padding: var(--space-2);
background: var(--lora-surface);
.image-metadata-panel::-webkit-scrollbar-track { border: 1px solid var(--lora-border);
background: transparent; border-radius: var(--border-radius-sm);
}
.image-metadata-panel::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 3px;
}
.image-metadata-panel {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
/* NSFW Content Styles */
.media-wrapper img.blurred,
.media-wrapper video.blurred {
filter: blur(25px);
}
.media-wrapper .nsfw-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 2;
pointer-events: none;
}
/* NSFW Filter Notification */
.nsfw-filter-notification {
background: var(--lora-warning);
color: var(--lora-text);
padding: var(--space-2);
border-radius: var(--border-radius-xs);
margin-bottom: var(--space-2);
display: flex;
align-items: center;
gap: 8px; gap: 8px;
margin-bottom: var(--space-2);
transition: background-color 0.2s, transform 0.2s;
}
.scroll-indicator:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
transform: translateY(-1px);
}
.scroll-indicator span {
font-size: 0.9em; font-size: 0.9em;
}
/* No examples message */
.no-examples {
text-align: center;
padding: var(--space-4);
color: var(--text-color); color: var(--text-color);
opacity: 0.7;
} }
/* Lazy loading */
.lazy { .lazy {
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
@@ -591,24 +386,93 @@
opacity: 1; opacity: 1;
} }
/* For dark theme */ /* Example Import Area */
[data-theme="dark"] .import-drop-zone { .example-import-area {
background: rgba(255, 255, 255, 0.03); margin-top: var(--space-4);
padding: var(--space-2);
} }
/* Responsive design for smaller screens */ .example-import-area.empty {
@media (max-width: 768px) { margin-top: var(--space-2);
.thumbnail-sidebar { padding: var(--space-4) var(--space-2);
width: 160px; }
}
.import-container {
.navigation-controls { border: 2px dashed var(--border-color);
top: var(--space-1); border-radius: var(--border-radius-sm);
right: var(--space-1); padding: var(--space-4);
} text-align: center;
transition: all 0.3s ease;
.nav-btn { background: var(--lora-surface);
width: 32px; cursor: pointer;
height: 32px; }
}
.import-container.highlight {
border-color: var(--lora-accent);
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
transform: scale(1.01);
}
.import-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding-top: var(--space-1);
}
.import-placeholder i {
font-size: 2.5rem;
/* color: var(--lora-accent); */
opacity: 0.8;
margin-bottom: var(--space-1);
}
.import-placeholder h3 {
margin: 0 0 var(--space-1);
font-size: 1.2rem;
font-weight: 500;
color: var(--text-color);
}
.import-placeholder p {
margin: var(--space-1) 0;
color: var(--text-color);
opacity: 0.8;
}
.import-placeholder .sub-text {
font-size: 0.9em;
opacity: 0.6;
margin: var(--space-1) 0;
}
.import-formats {
font-size: 0.8em !important;
opacity: 0.6 !important;
margin-top: var(--space-2) !important;
}
.select-files-btn {
background: var(--lora-accent);
color: var(--lora-text);
border: none;
border-radius: var(--border-radius-xs);
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-size: 0.9em;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
.select-files-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* For dark theme */
[data-theme="dark"] .import-container {
background: rgba(255, 255, 255, 0.03);
} }

View File

@@ -56,6 +56,24 @@
color: var(--lora-error); color: var(--lora-error);
} }
/* Update color scheme to include embeddings */
:root {
--embedding-color: oklch(68% 0.28 120); /* Green for embeddings */
}
/* Update metric cards and chart colors to support embeddings */
.metric-card.embedding .metric-icon {
color: var(--embedding-color);
}
.model-item.embedding {
border-left: 3px solid var(--embedding-color);
}
.model-item.embedding:hover {
border-color: var(--embedding-color);
}
/* Dashboard Content */ /* Dashboard Content */
.dashboard-content { .dashboard-content {
background: var(--card-bg); background: var(--card-bg);

View File

@@ -6,6 +6,7 @@
/* Import Components */ /* Import Components */
@import 'components/header.css'; @import 'components/header.css';
@import 'components/banner.css';
@import 'components/card.css'; @import 'components/card.css';
@import 'components/modal/_base.css'; @import 'components/modal/_base.css';
@import 'components/modal/delete-modal.css'; @import 'components/modal/delete-modal.css';

View File

@@ -1,6 +1,7 @@
import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js'; import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../../state/index.js'; import { state, getCurrentPageState } from '../../state/index.js';
import { showModelModal } from './ModelModal.js'; import { showModelModal } from './ModelModal.js';
import { toggleShowcase } from './showcase/ShowcaseView.js';
import { bulkManager } from '../../managers/BulkManager.js'; import { bulkManager } from '../../managers/BulkManager.js';
import { modalManager } from '../../managers/ModalManager.js'; import { modalManager } from '../../managers/ModalManager.js';
import { NSFW_LEVELS } from '../../utils/constants.js'; import { NSFW_LEVELS } from '../../utils/constants.js';
@@ -339,6 +340,15 @@ function showExampleAccessModal(card, modelType) {
tabBtn.click(); tabBtn.click();
} }
// Then toggle showcase if collapsed
const carousel = showcaseTab.querySelector('.carousel');
if (carousel && carousel.classList.contains('collapsed')) {
const scrollIndicator = showcaseTab.querySelector('.scroll-indicator');
if (scrollIndicator) {
toggleShowcase(scrollIndicator);
}
}
// Finally scroll to the import area // Finally scroll to the import area
importArea.scrollIntoView({ behavior: 'smooth' }); importArea.scrollIntoView({ behavior: 'smooth' });
} }
@@ -444,7 +454,7 @@ export function createModelCard(model, modelType) {
card.innerHTML = ` card.innerHTML = `
<div class="card-preview ${shouldBlur ? 'blurred' : ''}"> <div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${isVideo ? ${isVideo ?
`<video ${videoAttrs}> `<video ${videoAttrs} style="pointer-events: none;">
<source src="${versionedPreviewUrl}" type="video/mp4"> <source src="${versionedPreviewUrl}" type="video/mp4">
</video>` : </video>` :
`<img src="${versionedPreviewUrl}" alt="${model.model_name}">` `<img src="${versionedPreviewUrl}" alt="${model.model_name}">`

View File

@@ -1,6 +1,9 @@
import { showToast, openCivitai } from '../../utils/uiHelpers.js'; import { showToast, openCivitai } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js'; import { modalManager } from '../../managers/ModalManager.js';
import { import {
toggleShowcase,
setupShowcaseScroll,
scrollToTop,
loadExampleImages loadExampleImages
} from './showcase/ShowcaseView.js'; } from './showcase/ShowcaseView.js';
import { setupTabSwitching } from './ModelDescription.js'; import { setupTabSwitching } from './ModelDescription.js';
@@ -30,6 +33,7 @@ export function showModelModal(model, modelType) {
model.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : []; model.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
// Generate model type specific content // Generate model type specific content
// const typeSpecificContent = modelType === 'loras' ? renderLoraSpecificContent(model, escapedWords) : '';
let typeSpecificContent; let typeSpecificContent;
if (modelType === 'loras') { if (modelType === 'loras') {
typeSpecificContent = renderLoraSpecificContent(model, escapedWords); typeSpecificContent = renderLoraSpecificContent(model, escapedWords);
@@ -180,12 +184,17 @@ export function showModelModal(model, modelType) {
<div class="tab-content"> <div class="tab-content">
${tabPanesContent} ${tabPanesContent}
</div> </div>
<button class="back-to-top" data-action="scroll-to-top">
<i class="fas fa-arrow-up"></i>
</button>
</div> </div>
</div> </div>
</div> </div>
`; `;
const onCloseCallback = function() { const onCloseCallback = function() {
// Clean up all handlers when modal closes for LoRA
const modalElement = document.getElementById(modalId); const modalElement = document.getElementById(modalId);
if (modalElement && modalElement._clickHandler) { if (modalElement && modalElement._clickHandler) {
modalElement.removeEventListener('click', modalElement._clickHandler); modalElement.removeEventListener('click', modalElement._clickHandler);
@@ -195,6 +204,7 @@ export function showModelModal(model, modelType) {
modalManager.showModal(modalId, content, null, onCloseCallback); modalManager.showModal(modalId, content, null, onCloseCallback);
setupEditableFields(model.file_path, modelType); setupEditableFields(model.file_path, modelType);
setupShowcaseScroll(modalId);
setupTabSwitching(); setupTabSwitching();
setupTagTooltip(); setupTagTooltip();
setupTagEditMode(); setupTagEditMode();
@@ -213,9 +223,10 @@ export function showModelModal(model, modelType) {
} }
} }
// Load example images asynchronously // Load example images asynchronously - merge regular and custom images
const regularImages = model.civitai?.images || []; const regularImages = model.civitai?.images || [];
const customImages = model.civitai?.customImages || []; const customImages = model.civitai?.customImages || [];
// Combine images - regular images first, then custom images
const allImages = [...regularImages, ...customImages]; const allImages = [...regularImages, ...customImages];
loadExampleImages(allImages, model.sha256); loadExampleImages(allImages, model.sha256);
} }
@@ -250,14 +261,16 @@ function renderEmbeddingSpecificContent(embedding, escapedWords) {
} }
/** /**
* Sets up event handlers using event delegation for modal * Sets up event handlers using event delegation for LoRA modal
* @param {string} filePath - Path to the model file * @param {string} filePath - Path to the model file
*/ */
function setupEventHandlers(filePath) { function setupEventHandlers(filePath) {
const modalElement = document.getElementById('modelModal'); const modalElement = document.getElementById('modelModal');
// Remove existing event listeners first
modalElement.removeEventListener('click', handleModalClick); modalElement.removeEventListener('click', handleModalClick);
// Create and store the handler function
function handleModalClick(event) { function handleModalClick(event) {
const target = event.target.closest('[data-action]'); const target = event.target.closest('[data-action]');
if (!target) return; if (!target) return;
@@ -268,6 +281,9 @@ function setupEventHandlers(filePath) {
case 'close-modal': case 'close-modal':
modalManager.closeModal('modelModal'); modalManager.closeModal('modelModal');
break; break;
case 'scroll-to-top':
scrollToTop(target);
break;
case 'view-civitai': case 'view-civitai':
openCivitai(target.dataset.filepath); openCivitai(target.dataset.filepath);
break; break;
@@ -280,7 +296,10 @@ function setupEventHandlers(filePath) {
} }
} }
// Add the event listener with the named function
modalElement.addEventListener('click', handleModalClick); modalElement.addEventListener('click', handleModalClick);
// Store reference to the handler on the element for potential cleanup
modalElement._clickHandler = handleModalClick; modalElement._clickHandler = handleModalClick;
} }
@@ -402,7 +421,9 @@ async function saveNotes(filePath) {
// Export the model modal API // Export the model modal API
const modelModal = { const modelModal = {
show: showModelModal show: showModelModal,
toggleShowcase,
scrollToTop
}; };
export { modelModal }; export { modelModal };

View File

@@ -182,46 +182,119 @@ export function getRenderedMediaRect(mediaElement, containerWidth, containerHeig
* @param {HTMLElement} container - Container element with media wrappers * @param {HTMLElement} container - Container element with media wrappers
*/ */
export function initMetadataPanelHandlers(container) { export function initMetadataPanelHandlers(container) {
// Metadata panel interaction is now handled by the info button const mediaWrappers = container.querySelectorAll('.media-wrapper');
// Keep the existing copy functionality but remove hover-based visibility
const metadataPanel = container.querySelector('.image-metadata-panel');
if (metadataPanel) { mediaWrappers.forEach(wrapper => {
// Prevent events from bubbling // Get the metadata panel and media element (img or video)
metadataPanel.addEventListener('click', (e) => { const metadataPanel = wrapper.querySelector('.image-metadata-panel');
e.stopPropagation(); const mediaControls = wrapper.querySelector('.media-controls');
const mediaElement = wrapper.querySelector('img, video');
if (!mediaElement) return;
let isOverMetadataPanel = false;
// Add event listeners to the wrapper for mouse tracking
wrapper.addEventListener('mousemove', (e) => {
// Get mouse position relative to wrapper
const rect = wrapper.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Get the actual displayed dimensions of the media element
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
// Check if mouse is over the actual media content
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
// Show metadata panel and controls when over media content or metadata panel itself
if (isOverMedia || isOverMetadataPanel) {
if (metadataPanel) metadataPanel.classList.add('visible');
if (mediaControls) mediaControls.classList.add('visible');
} else {
if (metadataPanel) metadataPanel.classList.remove('visible');
if (mediaControls) mediaControls.classList.remove('visible');
}
}); });
// Handle copy prompt buttons wrapper.addEventListener('mouseleave', () => {
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn'); if (!isOverMetadataPanel) {
copyBtns.forEach(copyBtn => { if (metadataPanel) metadataPanel.classList.remove('visible');
const promptIndex = copyBtn.dataset.promptIndex; if (mediaControls) mediaControls.classList.remove('visible');
const promptElement = container.querySelector(`#prompt-${promptIndex}`); }
});
// Add mouse enter/leave events for the metadata panel itself
if (metadataPanel) {
metadataPanel.addEventListener('mouseenter', () => {
isOverMetadataPanel = true;
metadataPanel.classList.add('visible');
if (mediaControls) mediaControls.classList.add('visible');
});
copyBtn.addEventListener('click', async (e) => { metadataPanel.addEventListener('mouseleave', () => {
e.stopPropagation(); isOverMetadataPanel = false;
// Only hide if mouse is not over the media
const rect = wrapper.getBoundingClientRect();
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
if (!promptElement) return; const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
try { if (!isOverMedia) {
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard'); metadataPanel.classList.remove('visible');
} catch (err) { if (mediaControls) mediaControls.classList.remove('visible');
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
} }
}); });
});
// Prevent panel scroll from causing modal scroll
metadataPanel.addEventListener('wheel', (e) => {
const isAtTop = metadataPanel.scrollTop === 0;
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) { // Prevent events from bubbling
metadataPanel.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
} });
}, { passive: true });
} // Handle copy prompt buttons
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
copyBtns.forEach(copyBtn => {
const promptIndex = copyBtn.dataset.promptIndex;
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
copyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!promptElement) return;
try {
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
});
});
// Prevent panel scroll from causing modal scroll
metadataPanel.addEventListener('wheel', (e) => {
const isAtTop = metadataPanel.scrollTop === 0;
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
// Only prevent default if scrolling would cause the panel to scroll
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
e.stopPropagation();
}
}, { passive: true });
}
});
} }
/** /**
@@ -293,8 +366,9 @@ export function initMediaControlHandlers(container) {
btn.addEventListener('click', async function(e) { btn.addEventListener('click', async function(e) {
e.stopPropagation(); e.stopPropagation();
// Explicitly check for disabled state
if (this.classList.contains('disabled')) { if (this.classList.contains('disabled')) {
return; return; // Don't do anything if button is disabled
} }
const shortId = this.dataset.shortId; const shortId = this.dataset.shortId;
@@ -302,11 +376,14 @@ export function initMediaControlHandlers(container) {
if (!shortId) return; if (!shortId) return;
// Handle two-step confirmation
if (btnState === 'initial') { if (btnState === 'initial') {
// First click: show confirmation state
this.dataset.state = 'confirm'; this.dataset.state = 'confirm';
this.classList.add('confirm'); this.classList.add('confirm');
this.title = 'Click again to confirm deletion'; this.title = 'Click again to confirm deletion';
// Auto-reset after 3 seconds
setTimeout(() => { setTimeout(() => {
if (this.dataset.state === 'confirm') { if (this.dataset.state === 'confirm') {
this.dataset.state = 'initial'; this.dataset.state = 'initial';
@@ -318,16 +395,19 @@ export function initMediaControlHandlers(container) {
return; return;
} }
// Second click within 3 seconds: proceed with deletion
if (btnState === 'confirm') { if (btnState === 'confirm') {
this.disabled = true; this.disabled = true;
this.classList.remove('confirm'); this.classList.remove('confirm');
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; this.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
// Get model hash from URL or data attribute
const mediaWrapper = this.closest('.media-wrapper'); const mediaWrapper = this.closest('.media-wrapper');
const modelHashAttr = document.querySelector('.showcase-section')?.dataset; const modelHashAttr = document.querySelector('.showcase-section')?.dataset;
const modelHash = modelHashAttr?.modelHash; const modelHash = modelHashAttr?.modelHash;
try { try {
// Call the API to delete the custom example
const response = await fetch('/api/delete-example-image', { const response = await fetch('/api/delete-example-image', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -342,45 +422,32 @@ export function initMediaControlHandlers(container) {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
// Remove the corresponding thumbnail and update main display // Success: remove the media wrapper from the DOM
const thumbnailItem = container.querySelector(`.thumbnail-item[data-short-id="${shortId}"]`); mediaWrapper.style.opacity = '0';
if (thumbnailItem) { mediaWrapper.style.height = '0';
const wasActive = thumbnailItem.classList.contains('active'); mediaWrapper.style.transition = 'opacity 0.3s ease, height 0.3s ease 0.3s';
thumbnailItem.remove();
// If the deleted item was active, select next item
if (wasActive) {
const remainingThumbnails = container.querySelectorAll('.thumbnail-item');
if (remainingThumbnails.length > 0) {
remainingThumbnails[0].click();
} else {
// No more items, show empty state
const mainContainer = container.querySelector('#mainMediaContainer');
if (mainContainer) {
mainContainer.innerHTML = `
<div class="empty-state">
<i class="fas fa-images"></i>
<h3>No example images available</h3>
<p>Import images or videos using the sidebar</p>
</div>
`;
}
}
}
}
setTimeout(() => {
mediaWrapper.remove();
}, 600);
// Show success toast
showToast('Example image deleted', 'success'); showToast('Example image deleted', 'success');
// Create an update object with only the necessary properties
const updateData = { const updateData = {
civitai: { civitai: {
customImages: result.custom_images || [] customImages: result.custom_images || []
} }
}; };
// Update the item in the virtual scroller
state.virtualScroller.updateSingleItem(result.model_file_path, updateData); state.virtualScroller.updateSingleItem(result.model_file_path, updateData);
} else { } else {
// Show error message
showToast(result.error || 'Failed to delete example image', 'error'); showToast(result.error || 'Failed to delete example image', 'error');
// Reset button state
this.disabled = false; this.disabled = false;
this.dataset.state = 'initial'; this.dataset.state = 'initial';
this.classList.remove('confirm'); this.classList.remove('confirm');
@@ -391,6 +458,7 @@ export function initMediaControlHandlers(container) {
console.error('Error deleting example image:', error); console.error('Error deleting example image:', error);
showToast('Failed to delete example image', 'error'); showToast('Failed to delete example image', 'error');
// Reset button state
this.disabled = false; this.disabled = false;
this.dataset.state = 'initial'; this.dataset.state = 'initial';
this.classList.remove('confirm'); this.classList.remove('confirm');
@@ -401,7 +469,11 @@ export function initMediaControlHandlers(container) {
}); });
}); });
// Initialize set preview buttons
initSetPreviewHandlers(container); initSetPreviewHandlers(container);
// Media control visibility is now handled in initMetadataPanelHandlers
// Any click handlers or other functionality can still be added here
} }
/** /**
@@ -472,4 +544,50 @@ function initSetPreviewHandlers(container) {
} }
}); });
}); });
}
/**
* Position media controls within the actual rendered media rectangle
* @param {HTMLElement} mediaWrapper - The wrapper containing the media and controls
*/
export function positionMediaControlsInMediaRect(mediaWrapper) {
const mediaElement = mediaWrapper.querySelector('img, video');
const controlsElement = mediaWrapper.querySelector('.media-controls');
if (!mediaElement || !controlsElement) return;
// Get wrapper dimensions
const wrapperRect = mediaWrapper.getBoundingClientRect();
// Calculate the actual rendered media rectangle
const mediaRect = getRenderedMediaRect(
mediaElement,
wrapperRect.width,
wrapperRect.height
);
// Calculate the position for controls - place them inside the actual media area
const padding = 8; // Padding from the edge of the media
// Position at top-right inside the actual media rectangle
controlsElement.style.top = `${mediaRect.top + padding}px`;
controlsElement.style.right = `${wrapperRect.width - mediaRect.right + padding}px`;
// Also position any toggle blur buttons in the same way but on the left
const toggleBlurBtn = mediaWrapper.querySelector('.toggle-blur-btn');
if (toggleBlurBtn) {
toggleBlurBtn.style.top = `${mediaRect.top + padding}px`;
toggleBlurBtn.style.left = `${mediaRect.left + padding}px`;
}
}
/**
* Position all media controls in a container
* @param {HTMLElement} container - Container with media wrappers
*/
export function positionAllMediaControls(container) {
const mediaWrappers = container.querySelectorAll('.media-wrapper');
mediaWrappers.forEach(wrapper => {
positionMediaControlsInMediaRect(wrapper);
});
} }

View File

@@ -23,7 +23,6 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
const promptIndex = Math.random().toString(36).substring(2, 15); const promptIndex = Math.random().toString(36).substring(2, 15);
const negPromptIndex = Math.random().toString(36).substring(2, 15); const negPromptIndex = Math.random().toString(36).substring(2, 15);
// Note: Panel visibility is now controlled by the info button, not hover
let content = '<div class="image-metadata-panel"><div class="metadata-content">'; let content = '<div class="image-metadata-panel"><div class="metadata-content">';
if (hasParams) { if (hasParams) {

View File

@@ -9,7 +9,8 @@ import {
initLazyLoading, initLazyLoading,
initNsfwBlurHandlers, initNsfwBlurHandlers,
initMetadataPanelHandlers, initMetadataPanelHandlers,
initMediaControlHandlers initMediaControlHandlers,
positionAllMediaControls
} from './MediaUtils.js'; } from './MediaUtils.js';
import { generateMetadataPanel } from './MetadataPanel.js'; import { generateMetadataPanel } from './MetadataPanel.js';
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js'; import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
@@ -45,10 +46,13 @@ export async function loadExampleImages(images, modelHash) {
showcaseTab.innerHTML = renderShowcaseContent(images, localFiles); showcaseTab.innerHTML = renderShowcaseContent(images, localFiles);
// Re-initialize the showcase event listeners // Re-initialize the showcase event listeners
initShowcaseContent(showcaseTab); const carousel = showcaseTab.querySelector('.carousel');
if (carousel && !carousel.classList.contains('collapsed')) {
initShowcaseContent(carousel);
}
// Initialize the example import functionality // Initialize the example import functionality
// initExampleImport(modelHash, showcaseTab); initExampleImport(modelHash, showcaseTab);
} catch (error) { } catch (error) {
console.error('Error loading example images:', error); console.error('Error loading example images:', error);
const showcaseTab = document.getElementById('showcase-tab'); const showcaseTab = document.getElementById('showcase-tab');
@@ -67,13 +71,13 @@ export async function loadExampleImages(images, modelHash) {
* Render showcase content * Render showcase content
* @param {Array} images - Array of images/videos to show * @param {Array} images - Array of images/videos to show
* @param {Array} exampleFiles - Local example files * @param {Array} exampleFiles - Local example files
* @param {boolean} startExpanded - Whether to start in expanded state (unused in new design) * @param {boolean} startExpanded - Whether to start in expanded state
* @returns {string} HTML content * @returns {string} HTML content
*/ */
export function renderShowcaseContent(images, exampleFiles = [], startExpanded = false) { export function renderShowcaseContent(images, exampleFiles = [], startExpanded = false) {
if (!images?.length) { if (!images?.length) {
// Show empty state with import interface // Show empty state with import interface
return renderEmptyShowcase(); return renderImportInterface(true);
} }
// Filter images based on SFW setting // Filter images based on SFW setting
@@ -108,69 +112,29 @@ export function renderShowcaseContent(images, exampleFiles = [], startExpanded =
</div>` : ''; </div>` : '';
return ` return `
${hiddenNotification} <div class="scroll-indicator">
<div class="showcase-container"> <i class="fas fa-chevron-${startExpanded ? 'up' : 'down'}"></i>
<div class="thumbnail-sidebar" id="thumbnailSidebar"> <span>Scroll or click to ${startExpanded ? 'hide' : 'show'} ${filteredImages.length} examples</span>
<div class="thumbnail-grid"> </div>
${filteredImages.map((img, index) => renderThumbnail(img, index, exampleFiles)).join('')} <div class="carousel ${startExpanded ? '' : 'collapsed'}">
</div> ${hiddenNotification}
${renderImportInterface()} <div class="carousel-container">
</div> ${filteredImages.map((img, index) => renderMediaItem(img, index, exampleFiles)).join('')}
<div class="main-display-area">
<div class="navigation-controls">
<button class="nav-btn prev-btn" id="prevBtn" title="Previous (←)">
<i class="fas fa-chevron-left"></i>
</button>
<button class="nav-btn next-btn" id="nextBtn" title="Next (→)">
<i class="fas fa-chevron-right"></i>
</button>
<button class="nav-btn info-btn" id="infoBtn" title="Show/Hide Info (i)">
<i class="fas fa-info-circle"></i>
</button>
</div>
<div class="main-media-container" id="mainMediaContainer">
${filteredImages.length > 0 ? renderMainMediaItem(filteredImages[0], 0, exampleFiles) : ''}
</div>
</div> </div>
${renderImportInterface(false)}
</div> </div>
`; `;
} }
/** /**
* Find the matching local file for an image * Render a single media item (image or video)
* @param {Object} img - Image metadata
* @param {number} index - Image index
* @param {Array} exampleFiles - Array of local files
* @returns {Object|null} Matching local file or null
*/
function findLocalFile(img, index, exampleFiles) {
if (!exampleFiles || exampleFiles.length === 0) return null;
let localFile = null;
if (img.id) {
// This is a custom image, find by custom_<id>
const customPrefix = `custom_${img.id}`;
localFile = exampleFiles.find(file => file.name.startsWith(customPrefix));
} else {
// This is a regular image from civitai, find by index
localFile = exampleFiles.find(file => {
const match = file.name.match(/image_(\d+)\./);
return match && parseInt(match[1]) === index;
});
}
return localFile;
}
/**
* Render a thumbnail for the sidebar
* @param {Object} img - Image/video metadata * @param {Object} img - Image/video metadata
* @param {number} index - Index in the array * @param {number} index - Index in the array
* @param {Array} exampleFiles - Local files * @param {Array} exampleFiles - Local files
* @returns {string} HTML for the thumbnail * @returns {string} HTML for the media item
*/ */
function renderThumbnail(img, index, exampleFiles) { function renderMediaItem(img, index, exampleFiles) {
// Find matching file in our list of actual files // Find matching file in our list of actual files
let localFile = findLocalFile(img, index, exampleFiles); let localFile = findLocalFile(img, index, exampleFiles);
@@ -179,57 +143,15 @@ function renderThumbnail(img, index, exampleFiles) {
const isVideo = localFile ? localFile.is_video : const isVideo = localFile ? localFile.is_video :
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm'); remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
// Check if media should be blurred // Calculate appropriate aspect ratio
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0; const aspectRatio = (img.height / img.width) * 100;
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13; const containerWidth = 800; // modal content maximum width
const minHeightPercent = 40;
return ` const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
<div class="thumbnail-item ${index === 0 ? 'active' : ''}" const heightPercent = Math.max(
data-index="${index}" minHeightPercent,
data-nsfw-level="${nsfwLevel}" Math.min(maxHeightPercent, aspectRatio)
data-short-id="${img.id || ''}"> );
${isVideo ? `
<video class="thumbnail-media lazy ${shouldBlur ? 'blurred' : ''}"
data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
muted>
<source data-local-src="${localUrl || ''}" data-remote-src="${remoteUrl}" type="video/mp4">
</video>
<div class="video-indicator">
<i class="fas fa-play"></i>
</div>
` : `
<img class="thumbnail-media lazy ${shouldBlur ? 'blurred' : ''}"
data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
alt="Thumbnail"
width="${img.width}"
height="${img.height}">
`}
${shouldBlur ? `
<div class="thumbnail-nsfw-overlay">
<i class="fas fa-eye-slash"></i>
</div>
` : ''}
</div>
`;
}
/**
* Render the main media item in the display area
* @param {Object} img - Image/video metadata
* @param {number} index - Index in the array
* @param {Array} exampleFiles - Local files
* @returns {string} HTML for the main media item
*/
function renderMainMediaItem(img, index, exampleFiles) {
// Find matching file in our list of actual files
let localFile = findLocalFile(img, index, exampleFiles);
const remoteUrl = img.url || '';
const localUrl = localFile ? localFile.path : '';
const isVideo = localFile ? localFile.is_video :
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
// Check if media should be blurred // Check if media should be blurred
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0; const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
@@ -290,252 +212,380 @@ function renderMainMediaItem(img, index, exampleFiles) {
// Generate the appropriate wrapper based on media type // Generate the appropriate wrapper based on media type
if (isVideo) { if (isVideo) {
return generateVideoWrapper( return generateVideoWrapper(
img, 100, shouldBlur, nsfwText, metadataPanel, img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl, mediaControlsHtml localUrl, remoteUrl, mediaControlsHtml
); );
} }
return generateImageWrapper( return generateImageWrapper(
img, 100, shouldBlur, nsfwText, metadataPanel, img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl, mediaControlsHtml localUrl, remoteUrl, mediaControlsHtml
); );
} }
/** /**
* Render empty showcase with import interface * Find the matching local file for an image
* @returns {string} HTML content for empty showcase * @param {Object} img - Image metadata
* @param {number} index - Image index
* @param {Array} exampleFiles - Array of local files
* @returns {Object|null} Matching local file or null
*/ */
function renderEmptyShowcase() { function findLocalFile(img, index, exampleFiles) {
return ` if (!exampleFiles || exampleFiles.length === 0) return null;
<div class="showcase-container empty">
<div class="thumbnail-sidebar" id="thumbnailSidebar"> let localFile = null;
<div class="thumbnail-grid">
<!-- Empty thumbnails grid --> if (img.id) {
</div> // This is a custom image, find by custom_<id>
${renderImportInterface()} const customPrefix = `custom_${img.id}`;
</div> localFile = exampleFiles.find(file => file.name.startsWith(customPrefix));
<div class="main-display-area empty"> } else {
<div class="empty-state"> // This is a regular image from civitai, find by index
<i class="fas fa-images"></i> localFile = exampleFiles.find(file => {
<h3>No example images available</h3> const match = file.name.match(/image_(\d+)\./);
<p>Import images or videos using the sidebar</p> return match && parseInt(match[1]) === index;
</div> });
</div> }
</div>
`; return localFile;
} }
/** /**
* Render the import interface for example images * Render the import interface for example images
* @param {boolean} isEmpty - Whether there are no existing examples
* @returns {string} HTML content for import interface * @returns {string} HTML content for import interface
*/ */
function renderImportInterface() { function renderImportInterface(isEmpty) {
return ` return `
<div class="import-section"> <div class="example-import-area ${isEmpty ? 'empty' : ''}">
<button class="select-files-btn" id="selectExampleFilesBtn"> <div class="import-container" id="exampleImportContainer">
<i class="fas fa-plus"></i> <div class="import-placeholder">
<span>Add Images</span>
</button>
<div class="import-drop-zone" id="importDropZone">
<div class="drop-zone-content">
<i class="fas fa-cloud-upload-alt"></i> <i class="fas fa-cloud-upload-alt"></i>
<span>Drop here</span> <h3>${isEmpty ? 'No example images available' : 'Add more examples'}</h3>
<p>Drag & drop images or videos here</p>
<p class="sub-text">or</p>
<button class="select-files-btn" id="selectExampleFilesBtn">
<i class="fas fa-folder-open"></i> Select Files
</button>
<p class="import-formats">Supported formats: jpg, png, gif, webp, mp4, webm</p>
</div>
<input type="file" id="exampleFilesInput" multiple accept="image/*,video/mp4,video/webm" style="display: none;">
<div class="import-progress-container" style="display: none;">
<div class="import-progress">
<div class="progress-bar"></div>
</div>
<span class="progress-text">Importing files...</span>
</div> </div>
</div> </div>
<input type="file" id="exampleFilesInput" multiple accept="image/*,video/mp4,video/webm" style="display: none;">
</div> </div>
`; `;
} }
/** /**
* Initialize all showcase content interactions * Initialize the example import functionality
* @param {HTMLElement} showcase - The showcase element * @param {string} modelHash - The SHA256 hash of the model
* @param {Element} container - The container element for the import area
*/ */
export function initShowcaseContent(showcase) { export function initExampleImport(modelHash, container) {
if (!showcase) return;
const container = showcase.querySelector('.showcase-container');
if (!container) return; if (!container) return;
initLazyLoading(container); const importContainer = container.querySelector('#exampleImportContainer');
initNsfwBlurHandlers(container); const fileInput = container.querySelector('#exampleFilesInput');
initThumbnailNavigation(container); const selectFilesBtn = container.querySelector('#selectExampleFilesBtn');
initMainDisplayHandlers(container);
initMediaControlHandlers(container);
// Initialize keyboard navigation // Set up file selection button
initKeyboardNavigation(container); if (selectFilesBtn) {
} selectFilesBtn.addEventListener('click', () => {
fileInput.click();
/**
* Initialize thumbnail navigation
* @param {HTMLElement} container - The showcase container
*/
function initThumbnailNavigation(container) {
const thumbnails = container.querySelectorAll('.thumbnail-item');
const mainContainer = container.querySelector('#mainMediaContainer');
if (!mainContainer) return;
thumbnails.forEach((thumbnail, index) => {
thumbnail.addEventListener('click', () => {
// Update active thumbnail
thumbnails.forEach(t => t.classList.remove('active'));
thumbnail.classList.add('active');
// Get the corresponding image data and render main media
const showcaseSection = document.querySelector('.showcase-section');
const modelHash = showcaseSection?.dataset.modelHash;
// This would need access to the filtered images array
// For now, we'll trigger a re-render of the main display
updateMainDisplay(index, container);
}); });
});
}
/**
* Initialize main display handlers including navigation and info toggle
* @param {HTMLElement} container - The showcase container
*/
function initMainDisplayHandlers(container) {
const prevBtn = container.querySelector('#prevBtn');
const nextBtn = container.querySelector('#nextBtn');
const infoBtn = container.querySelector('#infoBtn');
if (prevBtn) {
prevBtn.addEventListener('click', () => navigateMedia(container, -1));
} }
if (nextBtn) { // Handle file selection
nextBtn.addEventListener('click', () => navigateMedia(container, 1)); if (fileInput) {
} fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
if (infoBtn) { handleImportFiles(Array.from(e.target.files), modelHash, importContainer);
infoBtn.addEventListener('click', () => toggleMetadataPanel(container));
}
// Initialize metadata panel toggle behavior
initMetadataPanelToggle(container);
}
/**
* Initialize keyboard navigation
* @param {HTMLElement} container - The showcase container
*/
function initKeyboardNavigation(container) {
document.addEventListener('keydown', (e) => {
// Only handle if showcase is visible and focused
if (!container.closest('.modal').classList.contains('show')) return;
switch(e.key) {
case 'ArrowLeft':
e.preventDefault();
navigateMedia(container, -1);
break;
case 'ArrowRight':
e.preventDefault();
navigateMedia(container, 1);
break;
case 'i':
case 'I':
e.preventDefault();
toggleMetadataPanel(container);
break;
}
});
}
/**
* Navigate to previous/next media item
* @param {HTMLElement} container - The showcase container
* @param {number} direction - -1 for previous, 1 for next
*/
function navigateMedia(container, direction) {
const thumbnails = container.querySelectorAll('.thumbnail-item');
const activeThumbnail = container.querySelector('.thumbnail-item.active');
if (!activeThumbnail || thumbnails.length === 0) return;
const currentIndex = Array.from(thumbnails).indexOf(activeThumbnail);
let newIndex = currentIndex + direction;
// Wrap around
if (newIndex < 0) newIndex = thumbnails.length - 1;
if (newIndex >= thumbnails.length) newIndex = 0;
// Click the new thumbnail to trigger the display update
thumbnails[newIndex].click();
}
/**
* Toggle metadata panel visibility
* @param {HTMLElement} container - The showcase container
*/
function toggleMetadataPanel(container) {
const metadataPanel = container.querySelector('.image-metadata-panel');
const infoBtn = container.querySelector('#infoBtn');
if (!metadataPanel || !infoBtn) return;
const isVisible = metadataPanel.classList.contains('visible');
if (isVisible) {
metadataPanel.classList.remove('visible');
infoBtn.classList.remove('active');
} else {
metadataPanel.classList.add('visible');
infoBtn.classList.add('active');
}
}
/**
* Initialize metadata panel toggle behavior
* @param {HTMLElement} container - The showcase container
*/
function initMetadataPanelToggle(container) {
const metadataPanel = container.querySelector('.image-metadata-panel');
if (!metadataPanel) return;
// Handle copy prompt buttons
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
copyBtns.forEach(copyBtn => {
const promptIndex = copyBtn.dataset.promptIndex;
const promptElement = container.querySelector(`#prompt-${promptIndex}`);
copyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!promptElement) return;
try {
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
} }
}); });
}); }
// Prevent panel scroll from causing modal scroll // Set up drag and drop
metadataPanel.addEventListener('wheel', (e) => { if (importContainer) {
const isAtTop = metadataPanel.scrollTop === 0; ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight; importContainer.addEventListener(eventName, preventDefaults, false);
});
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) { function preventDefaults(e) {
e.preventDefault();
e.stopPropagation(); e.stopPropagation();
} }
}, { passive: true });
// Highlight drop area on drag over
['dragenter', 'dragover'].forEach(eventName => {
importContainer.addEventListener(eventName, () => {
importContainer.classList.add('highlight');
}, false);
});
// Remove highlight on drag leave
['dragleave', 'drop'].forEach(eventName => {
importContainer.addEventListener(eventName, () => {
importContainer.classList.remove('highlight');
}, false);
});
// Handle dropped files
importContainer.addEventListener('drop', (e) => {
const files = Array.from(e.dataTransfer.files);
handleImportFiles(files, modelHash, importContainer);
}, false);
}
} }
/** /**
* Update main display with new media item * Handle the file import process
* @param {number} index - Index of the media to display * @param {File[]} files - Array of files to import
* @param {HTMLElement} container - The showcase container * @param {string} modelHash - The SHA256 hash of the model
* @param {Element} importContainer - The container element for import UI
*/ */
function updateMainDisplay(index, container) { async function handleImportFiles(files, modelHash, importContainer) {
// This function would need to re-render the main display area // Filter for supported file types
// Implementation depends on how the image data is stored and accessed const supportedImages = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
console.log('Update main display to index:', index); const supportedVideos = ['.mp4', '.webm'];
const supportedExtensions = [...supportedImages, ...supportedVideos];
const validFiles = files.filter(file => {
const ext = '.' + file.name.split('.').pop().toLowerCase();
return supportedExtensions.includes(ext);
});
if (validFiles.length === 0) {
alert('No supported files selected. Please select image or video files.');
return;
}
try {
// Use FormData to upload files
const formData = new FormData();
formData.append('model_hash', modelHash);
validFiles.forEach(file => {
formData.append('files', file);
});
// Call API to import files
const response = await fetch('/api/import-example-images', {
method: 'POST',
body: formData
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to import example files');
}
// Get updated local files
const updatedFilesResponse = await fetch(`/api/example-image-files?model_hash=${modelHash}`);
const updatedFilesResult = await updatedFilesResponse.json();
if (!updatedFilesResult.success) {
throw new Error(updatedFilesResult.error || 'Failed to get updated file list');
}
// Re-render the showcase content
const showcaseTab = document.getElementById('showcase-tab');
if (showcaseTab) {
// Get the updated images from the result
const regularImages = result.regular_images || [];
const customImages = result.custom_images || [];
// Combine both arrays for rendering
const allImages = [...regularImages, ...customImages];
showcaseTab.innerHTML = renderShowcaseContent(allImages, updatedFilesResult.files, true);
// Re-initialize showcase functionality
const carousel = showcaseTab.querySelector('.carousel');
if (carousel && !carousel.classList.contains('collapsed')) {
initShowcaseContent(carousel);
}
// Initialize the import UI for the new content
initExampleImport(modelHash, showcaseTab);
showToast('Example images imported successfully', 'success');
// Update VirtualScroller if available
if (state.virtualScroller && result.model_file_path) {
// Create an update object with only the necessary properties
const updateData = {
civitai: {
images: regularImages,
customImages: customImages
}
};
// Update the item in the virtual scroller
state.virtualScroller.updateSingleItem(result.model_file_path, updateData);
}
}
} catch (error) {
console.error('Error importing examples:', error);
showToast(`Failed to import example images: ${error.message}`, 'error');
}
}
/**
* Toggle showcase expansion
* @param {HTMLElement} element - The scroll indicator element
*/
export function toggleShowcase(element) {
const carousel = element.nextElementSibling;
const isCollapsed = carousel.classList.contains('collapsed');
const indicator = element.querySelector('span');
const icon = element.querySelector('i');
carousel.classList.toggle('collapsed');
if (isCollapsed) {
const count = carousel.querySelectorAll('.media-wrapper').length;
indicator.textContent = `Scroll or click to hide examples`;
icon.classList.replace('fa-chevron-down', 'fa-chevron-up');
initShowcaseContent(carousel);
} else {
const count = carousel.querySelectorAll('.media-wrapper').length;
indicator.textContent = `Scroll or click to show ${count} examples`;
icon.classList.replace('fa-chevron-up', 'fa-chevron-down');
// Make sure any open metadata panels get closed
const carouselContainer = carousel.querySelector('.carousel-container');
if (carouselContainer) {
carouselContainer.style.height = '0';
setTimeout(() => {
carouselContainer.style.height = '';
}, 300);
}
}
}
/**
* Initialize all showcase content interactions
* @param {HTMLElement} carousel - The carousel element
*/
export function initShowcaseContent(carousel) {
if (!carousel) return;
initLazyLoading(carousel);
initNsfwBlurHandlers(carousel);
initMetadataPanelHandlers(carousel);
initMediaControlHandlers(carousel);
positionAllMediaControls(carousel);
// Bind scroll-indicator click to toggleShowcase
const scrollIndicator = carousel.previousElementSibling;
if (scrollIndicator && scrollIndicator.classList.contains('scroll-indicator')) {
// Remove previous click listeners to avoid duplicates
scrollIndicator.onclick = null;
scrollIndicator.removeEventListener('click', scrollIndicator._toggleShowcaseHandler);
scrollIndicator._toggleShowcaseHandler = () => toggleShowcase(scrollIndicator);
scrollIndicator.addEventListener('click', scrollIndicator._toggleShowcaseHandler);
}
// Add window resize handler
const resizeHandler = () => positionAllMediaControls(carousel);
window.removeEventListener('resize', resizeHandler);
window.addEventListener('resize', resizeHandler);
// Handle images loading which might change dimensions
const mediaElements = carousel.querySelectorAll('img, video');
mediaElements.forEach(media => {
media.addEventListener('load', () => positionAllMediaControls(carousel));
if (media.tagName === 'VIDEO') {
media.addEventListener('loadedmetadata', () => positionAllMediaControls(carousel));
}
});
}
/**
* Scroll to top of modal content
* @param {HTMLElement} button - Back to top button
*/
export function scrollToTop(button) {
const modalContent = button.closest('.modal-content');
if (modalContent) {
modalContent.scrollTo({
top: 0,
behavior: 'smooth'
});
}
}
/**
* Set up showcase scroll functionality
* @param {string} modalId - ID of the modal element
*/
export function setupShowcaseScroll(modalId) {
// Listen for wheel events
document.addEventListener('wheel', (event) => {
const modalContent = document.querySelector(`#${modalId} .modal-content`);
if (!modalContent) return;
const showcase = modalContent.querySelector('.showcase-section');
if (!showcase) return;
const carousel = showcase.querySelector('.carousel');
const scrollIndicator = showcase.querySelector('.scroll-indicator');
if (carousel?.classList.contains('collapsed') && event.deltaY > 0) {
const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100;
if (isNearBottom) {
toggleShowcase(scrollIndicator);
event.preventDefault();
}
}
}, { passive: false });
// Use MutationObserver to set up back-to-top button when modal content is added
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
const modal = document.getElementById(modalId);
if (modal && modal.querySelector('.modal-content')) {
setupBackToTopButton(modal.querySelector('.modal-content'));
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Try to set up the button immediately in case the modal is already open
const modalContent = document.querySelector(`#${modalId} .modal-content`);
if (modalContent) {
setupBackToTopButton(modalContent);
}
}
/**
* Set up back-to-top button
* @param {HTMLElement} modalContent - Modal content element
*/
function setupBackToTopButton(modalContent) {
// Remove any existing scroll listeners to avoid duplicates
modalContent.onscroll = null;
// Add new scroll listener
modalContent.addEventListener('scroll', () => {
const backToTopBtn = modalContent.querySelector('.back-to-top');
if (backToTopBtn) {
if (modalContent.scrollTop > 300) {
backToTopBtn.classList.add('visible');
} else {
backToTopBtn.classList.remove('visible');
}
}
});
// Trigger a scroll event to check initial position
modalContent.dispatchEvent(new Event('scroll'));
} }

View File

@@ -7,6 +7,7 @@ import { HeaderManager } from './components/Header.js';
import { settingsManager } from './managers/SettingsManager.js'; import { settingsManager } from './managers/SettingsManager.js';
import { exampleImagesManager } from './managers/ExampleImagesManager.js'; import { exampleImagesManager } from './managers/ExampleImagesManager.js';
import { helpManager } from './managers/HelpManager.js'; import { helpManager } from './managers/HelpManager.js';
import { bannerService } from './managers/BannerService.js';
import { showToast, initTheme, initBackToTop } from './utils/uiHelpers.js'; import { showToast, initTheme, initBackToTop } from './utils/uiHelpers.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { migrateStorageItems } from './utils/storageHelpers.js'; import { migrateStorageItems } from './utils/storageHelpers.js';
@@ -27,6 +28,7 @@ export class AppCore {
state.loadingManager = new LoadingManager(); state.loadingManager = new LoadingManager();
modalManager.initialize(); modalManager.initialize();
updateService.initialize(); updateService.initialize();
bannerService.initialize();
window.modalManager = modalManager; window.modalManager = modalManager;
window.settingsManager = settingsManager; window.settingsManager = settingsManager;
window.exampleImagesManager = exampleImagesManager; window.exampleImagesManager = exampleImagesManager;

View File

@@ -0,0 +1,176 @@
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
/**
* Banner Service for managing notification banners
*/
class BannerService {
constructor() {
this.banners = new Map();
this.container = null;
this.initialized = false;
}
/**
* Initialize the banner service
*/
initialize() {
if (this.initialized) return;
this.container = document.getElementById('banner-container');
if (!this.container) {
console.warn('Banner container not found');
return;
}
// Register default banners
this.registerBanner('civitai-extension', {
id: 'civitai-extension',
title: 'New Tool Available: LM Civitai Extension!',
content: 'LM Civitai Extension is a browser extension designed to work seamlessly with LoRA Manager to significantly enhance your Civitai browsing experience! See which models you already have, download new ones with a single click, and manage your downloads efficiently.',
actions: [
{
text: 'Chrome Web Store',
icon: 'fab fa-chrome',
url: 'https://chromewebstore.google.com/detail/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb',
type: 'secondary'
},
{
text: 'Firefox Extension',
icon: 'fab fa-firefox-browser',
url: 'https://github.com/willmiao/lm-civitai-extension-firefox/releases/latest/download/extension.xpi',
type: 'secondary'
},
{
text: 'Read more...',
icon: 'fas fa-book',
url: 'https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/LoRA-Manager-Civitai-Extension-(Chrome-Extension)',
type: 'tertiary'
}
],
dismissible: true,
priority: 1
});
this.showActiveBanners();
this.initialized = true;
}
/**
* Register a new banner
* @param {string} id - Unique banner ID
* @param {Object} bannerConfig - Banner configuration
*/
registerBanner(id, bannerConfig) {
this.banners.set(id, bannerConfig);
}
/**
* Check if a banner has been dismissed
* @param {string} bannerId - Banner ID
* @returns {boolean}
*/
isBannerDismissed(bannerId) {
const dismissedBanners = getStorageItem('dismissed_banners', []);
return dismissedBanners.includes(bannerId);
}
/**
* Dismiss a banner
* @param {string} bannerId - Banner ID
*/
dismissBanner(bannerId) {
const dismissedBanners = getStorageItem('dismissed_banners', []);
if (!dismissedBanners.includes(bannerId)) {
dismissedBanners.push(bannerId);
setStorageItem('dismissed_banners', dismissedBanners);
}
// Remove banner from DOM
const bannerElement = document.querySelector(`[data-banner-id="${bannerId}"]`);
if (bannerElement) {
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
setTimeout(() => {
bannerElement.remove();
this.updateContainerVisibility();
}, 300);
}
}
/**
* Show all active (non-dismissed) banners
*/
showActiveBanners() {
if (!this.container) return;
const activeBanners = Array.from(this.banners.values())
.filter(banner => !this.isBannerDismissed(banner.id))
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
activeBanners.forEach(banner => {
this.renderBanner(banner);
});
this.updateContainerVisibility();
}
/**
* Render a banner to the DOM
* @param {Object} banner - Banner configuration
*/
renderBanner(banner) {
const bannerElement = document.createElement('div');
bannerElement.className = 'banner-item';
bannerElement.setAttribute('data-banner-id', banner.id);
const actionsHtml = banner.actions ? banner.actions.map(action =>
`<a href="${action.url}" target="_blank" class="banner-action banner-action-${action.type}" rel="noopener noreferrer">
<i class="${action.icon}"></i>
<span>${action.text}</span>
</a>`
).join('') : '';
const dismissButtonHtml = banner.dismissible ?
`<button class="banner-dismiss" onclick="bannerService.dismissBanner('${banner.id}')" title="Dismiss">
<i class="fas fa-times"></i>
</button>` : '';
bannerElement.innerHTML = `
<div class="banner-content">
<div class="banner-text">
<h4 class="banner-title">${banner.title}</h4>
<p class="banner-description">${banner.content}</p>
</div>
<div class="banner-actions">
${actionsHtml}
</div>
</div>
${dismissButtonHtml}
`;
this.container.appendChild(bannerElement);
}
/**
* Update container visibility based on active banners
*/
updateContainerVisibility() {
if (!this.container) return;
const hasActiveBanners = this.container.children.length > 0;
this.container.style.display = hasActiveBanners ? 'block' : 'none';
}
/**
* Clear all dismissed banners (for testing/admin purposes)
*/
clearDismissedBanners() {
setStorageItem('dismissed_banners', []);
location.reload();
}
}
// Create and export singleton instance
export const bannerService = new BannerService();
// Make it globally available
window.bannerService = bannerService;

View File

@@ -67,6 +67,11 @@ export class SettingsManager {
if (state.global.settings.base_model_path_mappings === undefined) { if (state.global.settings.base_model_path_mappings === undefined) {
state.global.settings.base_model_path_mappings = {}; state.global.settings.base_model_path_mappings = {};
} }
// Set default for defaultEmbeddingRoot if undefined
if (state.global.settings.default_embedding_root === undefined) {
state.global.settings.default_embedding_root = '';
}
} }
initialize() { initialize() {
@@ -151,6 +156,9 @@ export class SettingsManager {
// Load default checkpoint root // Load default checkpoint root
await this.loadCheckpointRoots(); await this.loadCheckpointRoots();
// Load default embedding root
await this.loadEmbeddingRoots();
// Backend settings are loaded from the template directly // Backend settings are loaded from the template directly
} }
@@ -233,6 +241,45 @@ export class SettingsManager {
} }
} }
async loadEmbeddingRoots() {
try {
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
if (!defaultEmbeddingRootSelect) return;
// Fetch embedding roots
const response = await fetch('/api/embeddings/roots');
if (!response.ok) {
throw new Error('Failed to fetch embedding roots');
}
const data = await response.json();
if (!data.roots || data.roots.length === 0) {
throw new Error('No embedding roots found');
}
// Clear existing options except the first one (No Default)
const noDefaultOption = defaultEmbeddingRootSelect.querySelector('option[value=""]');
defaultEmbeddingRootSelect.innerHTML = '';
defaultEmbeddingRootSelect.appendChild(noDefaultOption);
// Add options for each root
data.roots.forEach(root => {
const option = document.createElement('option');
option.value = root;
option.textContent = root;
defaultEmbeddingRootSelect.appendChild(option);
});
// Set selected value from settings
const defaultRoot = state.global.settings.default_embedding_root || '';
defaultEmbeddingRootSelect.value = defaultRoot;
} catch (error) {
console.error('Error loading embedding roots:', error);
showToast('Failed to load embedding roots: ' + error.message, 'error');
}
}
loadBaseModelMappings() { loadBaseModelMappings() {
const mappingsContainer = document.getElementById('baseModelMappingsContainer'); const mappingsContainer = document.getElementById('baseModelMappingsContainer');
if (!mappingsContainer) return; if (!mappingsContainer) return;
@@ -508,6 +555,8 @@ export class SettingsManager {
state.global.settings.default_loras_root = value; state.global.settings.default_loras_root = value;
} else if (settingKey === 'default_checkpoint_root') { } else if (settingKey === 'default_checkpoint_root') {
state.global.settings.default_checkpoint_root = value; state.global.settings.default_checkpoint_root = value;
} else if (settingKey === 'default_embedding_root') {
state.global.settings.default_embedding_root = value;
} else if (settingKey === 'display_density') { } else if (settingKey === 'display_density') {
state.global.settings.displayDensity = value; state.global.settings.displayDensity = value;
@@ -528,7 +577,7 @@ export class SettingsManager {
try { try {
// For backend settings, make API call // For backend settings, make API call
if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'download_path_template') { if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'default_embedding_root' || settingKey === 'download_path_template') {
const payload = {}; const payload = {};
payload[settingKey] = value; payload[settingKey] = value;

View File

@@ -358,9 +358,10 @@ export class UpdateService {
<i class="fas fa-check-circle" style="margin-right: 8px;"></i> <i class="fas fa-check-circle" style="margin-right: 8px;"></i>
Successfully updated to ${newVersion}! Successfully updated to ${newVersion}!
<br><br> <br><br>
<small style="opacity: 0.8;"> <div style="opacity: 0.95; color: var(--lora-error); font-size: 1em;">
Please restart ComfyUI to complete the update process. Please restart ComfyUI or LoRA Manager to apply update.<br>
</small> Make sure to reload your browser for both LoRA Manager and ComfyUI.
</div>
</div> </div>
`; `;
} }
@@ -370,10 +371,10 @@ export class UpdateService {
this.updateAvailable = false; this.updateAvailable = false;
// Refresh the modal content // Refresh the modal content
setTimeout(() => { // setTimeout(() => {
this.updateModalContent(); // this.updateModalContent();
this.showUpdateProgress(false); // this.showUpdateProgress(false);
}, 2000); // }, 2000);
} }
// Simple markdown parser for changelog items // Simple markdown parser for changelog items

View File

@@ -150,6 +150,12 @@ class StatisticsManager {
value: this.data.collection.checkpoint_count, value: this.data.collection.checkpoint_count,
label: 'Checkpoints', label: 'Checkpoints',
format: 'number' format: 'number'
},
{
icon: 'fas fa-code',
value: this.data.collection.embedding_count,
label: 'Embeddings',
format: 'number'
} }
]; ];
@@ -195,7 +201,9 @@ class StatisticsManager {
if (!this.data.collection) return 0; if (!this.data.collection) return 0;
const totalModels = this.data.collection.total_models; const totalModels = this.data.collection.total_models;
const unusedModels = this.data.collection.unused_loras + this.data.collection.unused_checkpoints; const unusedModels = this.data.collection.unused_loras +
this.data.collection.unused_checkpoints +
this.data.collection.unused_embeddings;
const usedModels = totalModels - unusedModels; const usedModels = totalModels - unusedModels;
return totalModels > 0 ? (usedModels / totalModels) * 100 : 0; return totalModels > 0 ? (usedModels / totalModels) * 100 : 0;
@@ -233,12 +241,17 @@ class StatisticsManager {
if (!ctx || !this.data.collection) return; if (!ctx || !this.data.collection) return;
const data = { const data = {
labels: ['LoRAs', 'Checkpoints'], labels: ['LoRAs', 'Checkpoints', 'Embeddings'],
datasets: [{ datasets: [{
data: [this.data.collection.lora_count, this.data.collection.checkpoint_count], data: [
this.data.collection.lora_count,
this.data.collection.checkpoint_count,
this.data.collection.embedding_count
],
backgroundColor: [ backgroundColor: [
'oklch(68% 0.28 256)', 'oklch(68% 0.28 256)',
'oklch(68% 0.28 200)' 'oklch(68% 0.28 200)',
'oklch(68% 0.28 120)'
], ],
borderWidth: 2, borderWidth: 2,
borderColor: getComputedStyle(document.documentElement).getPropertyValue('--border-color') borderColor: getComputedStyle(document.documentElement).getPropertyValue('--border-color')
@@ -266,8 +279,13 @@ class StatisticsManager {
const loraData = this.data.baseModels.loras; const loraData = this.data.baseModels.loras;
const checkpointData = this.data.baseModels.checkpoints; const checkpointData = this.data.baseModels.checkpoints;
const embeddingData = this.data.baseModels.embeddings;
const allModels = new Set([...Object.keys(loraData), ...Object.keys(checkpointData)]); const allModels = new Set([
...Object.keys(loraData),
...Object.keys(checkpointData),
...Object.keys(embeddingData)
]);
const data = { const data = {
labels: Array.from(allModels), labels: Array.from(allModels),
@@ -281,6 +299,11 @@ class StatisticsManager {
label: 'Checkpoints', label: 'Checkpoints',
data: Array.from(allModels).map(model => checkpointData[model] || 0), data: Array.from(allModels).map(model => checkpointData[model] || 0),
backgroundColor: 'oklch(68% 0.28 200 / 0.7)' backgroundColor: 'oklch(68% 0.28 200 / 0.7)'
},
{
label: 'Embeddings',
data: Array.from(allModels).map(model => embeddingData[model] || 0),
backgroundColor: 'oklch(68% 0.28 120 / 0.7)'
} }
] ]
}; };
@@ -325,6 +348,13 @@ class StatisticsManager {
borderColor: 'oklch(68% 0.28 200)', borderColor: 'oklch(68% 0.28 200)',
backgroundColor: 'oklch(68% 0.28 200 / 0.1)', backgroundColor: 'oklch(68% 0.28 200 / 0.1)',
fill: true fill: true
},
{
label: 'Embedding Usage',
data: timeline.map(item => item.embedding_usage),
borderColor: 'oklch(68% 0.28 120)',
backgroundColor: 'oklch(68% 0.28 120 / 0.1)',
fill: true
} }
] ]
}; };
@@ -365,11 +395,13 @@ class StatisticsManager {
const topLoras = this.data.usage.top_loras || []; const topLoras = this.data.usage.top_loras || [];
const topCheckpoints = this.data.usage.top_checkpoints || []; const topCheckpoints = this.data.usage.top_checkpoints || [];
const topEmbeddings = this.data.usage.top_embeddings || [];
// Combine and sort all models by usage // Combine and sort all models by usage
const allModels = [ const allModels = [
...topLoras.map(m => ({ ...m, type: 'LoRA' })), ...topLoras.map(m => ({ ...m, type: 'LoRA' })),
...topCheckpoints.map(m => ({ ...m, type: 'Checkpoint' })) ...topCheckpoints.map(m => ({ ...m, type: 'Checkpoint' })),
...topEmbeddings.map(m => ({ ...m, type: 'Embedding' }))
].sort((a, b) => b.usage_count - a.usage_count).slice(0, 10); ].sort((a, b) => b.usage_count - a.usage_count).slice(0, 10);
const data = { const data = {
@@ -377,9 +409,14 @@ class StatisticsManager {
datasets: [{ datasets: [{
label: 'Usage Count', label: 'Usage Count',
data: allModels.map(model => model.usage_count), data: allModels.map(model => model.usage_count),
backgroundColor: allModels.map(model => backgroundColor: allModels.map(model => {
model.type === 'LoRA' ? 'oklch(68% 0.28 256)' : 'oklch(68% 0.28 200)' switch(model.type) {
) case 'LoRA': return 'oklch(68% 0.28 256)';
case 'Checkpoint': return 'oklch(68% 0.28 200)';
case 'Embedding': return 'oklch(68% 0.28 120)';
default: return 'oklch(68% 0.28 256)';
}
})
}] }]
}; };
@@ -404,12 +441,17 @@ class StatisticsManager {
if (!ctx || !this.data.collection) return; if (!ctx || !this.data.collection) return;
const data = { const data = {
labels: ['LoRAs', 'Checkpoints'], labels: ['LoRAs', 'Checkpoints', 'Embeddings'],
datasets: [{ datasets: [{
data: [this.data.collection.lora_size, this.data.collection.checkpoint_size], data: [
this.data.collection.lora_size,
this.data.collection.checkpoint_size,
this.data.collection.embedding_size
],
backgroundColor: [ backgroundColor: [
'oklch(68% 0.28 256)', 'oklch(68% 0.28 256)',
'oklch(68% 0.28 200)' 'oklch(68% 0.28 200)',
'oklch(68% 0.28 120)'
] ]
}] }]
}; };
@@ -443,10 +485,12 @@ class StatisticsManager {
const loraData = this.data.storage.loras || []; const loraData = this.data.storage.loras || [];
const checkpointData = this.data.storage.checkpoints || []; const checkpointData = this.data.storage.checkpoints || [];
const embeddingData = this.data.storage.embeddings || [];
const allData = [ const allData = [
...loraData.map(item => ({ ...item, type: 'LoRA' })), ...loraData.map(item => ({ ...item, type: 'LoRA' })),
...checkpointData.map(item => ({ ...item, type: 'Checkpoint' })) ...checkpointData.map(item => ({ ...item, type: 'Checkpoint' })),
...embeddingData.map(item => ({ ...item, type: 'Embedding' }))
]; ];
const data = { const data = {
@@ -458,9 +502,14 @@ class StatisticsManager {
name: item.name, name: item.name,
type: item.type type: item.type
})), })),
backgroundColor: allData.map(item => backgroundColor: allData.map(item => {
item.type === 'LoRA' ? 'oklch(68% 0.28 256 / 0.6)' : 'oklch(68% 0.28 200 / 0.6)' switch(item.type) {
) case 'LoRA': return 'oklch(68% 0.28 256 / 0.6)';
case 'Checkpoint': return 'oklch(68% 0.28 200 / 0.6)';
case 'Embedding': return 'oklch(68% 0.28 120 / 0.6)';
default: return 'oklch(68% 0.28 256 / 0.6)';
}
})
}] }]
}; };
@@ -502,6 +551,7 @@ class StatisticsManager {
renderTopModelsLists() { renderTopModelsLists() {
this.renderTopLorasList(); this.renderTopLorasList();
this.renderTopCheckpointsList(); this.renderTopCheckpointsList();
this.renderTopEmbeddingsList();
this.renderLargestModelsList(); this.renderLargestModelsList();
} }
@@ -555,17 +605,44 @@ class StatisticsManager {
`).join(''); `).join('');
} }
renderTopEmbeddingsList() {
const container = document.getElementById('topEmbeddingsList');
if (!container || !this.data.usage?.top_embeddings) return;
const topEmbeddings = this.data.usage.top_embeddings;
if (topEmbeddings.length === 0) {
container.innerHTML = '<div class="loading-placeholder">No usage data available</div>';
return;
}
container.innerHTML = topEmbeddings.map(embedding => `
<div class="model-item">
<img src="${embedding.preview_url || '/loras_static/images/no-preview.png'}"
alt="${embedding.name}" class="model-preview"
onerror="this.src='/loras_static/images/no-preview.png'">
<div class="model-info">
<div class="model-name" title="${embedding.name}">${embedding.name}</div>
<div class="model-meta">${embedding.base_model}${embedding.folder}</div>
</div>
<div class="model-usage">${embedding.usage_count}</div>
</div>
`).join('');
}
renderLargestModelsList() { renderLargestModelsList() {
const container = document.getElementById('largestModelsList'); const container = document.getElementById('largestModelsList');
if (!container || !this.data.storage) return; if (!container || !this.data.storage) return;
const loraModels = this.data.storage.loras || []; const loraModels = this.data.storage.loras || [];
const checkpointModels = this.data.storage.checkpoints || []; const checkpointModels = this.data.storage.checkpoints || [];
const embeddingModels = this.data.storage.embeddings || [];
// Combine and sort by size // Combine and sort by size
const allModels = [ const allModels = [
...loraModels.map(m => ({ ...m, type: 'LoRA' })), ...loraModels.map(m => ({ ...m, type: 'LoRA' })),
...checkpointModels.map(m => ({ ...m, type: 'Checkpoint' })) ...checkpointModels.map(m => ({ ...m, type: 'Checkpoint' })),
...embeddingModels.map(m => ({ ...m, type: 'Embedding' }))
].sort((a, b) => b.size - a.size).slice(0, 10); ].sort((a, b) => b.size - a.size).slice(0, 10);
if (allModels.length === 0) { if (allModels.length === 0) {

View File

@@ -141,7 +141,8 @@ export function migrateStorageItems() {
'recipes_search_prefs', 'recipes_search_prefs',
'checkpoints_search_prefs', 'checkpoints_search_prefs',
'show_update_notifications', 'show_update_notifications',
'last_update_check' 'last_update_check',
'dismissed_banners'
]; ];
// Migrate each known key // Migrate each known key

View File

@@ -82,6 +82,11 @@
</button> </button>
<div class="container"> <div class="container">
<!-- Banner component -->
<div id="banner-container" class="banner-container" style="display: none;">
<!-- Banners will be dynamically inserted here -->
</div>
{% if is_initializing %} {% if is_initializing %}
<!-- Show initialization component when initializing --> <!-- Show initialization component when initializing -->
{% include 'components/initialization.html' %} {% include 'components/initialization.html' %}

View File

@@ -128,6 +128,23 @@
Set the default checkpoint root directory for downloads, imports and moves Set the default checkpoint root directory for downloads, imports and moves
</div> </div>
</div> </div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="defaultEmbeddingRoot">Default Embedding Root</label>
</div>
<div class="setting-control select-control">
<select id="defaultEmbeddingRoot" onchange="settingsManager.saveSelectSetting('defaultEmbeddingRoot', 'default_embedding_root')">
<option value="">No Default</option>
<!-- Options will be loaded dynamically -->
</select>
</div>
</div>
<div class="input-help">
Set the default embedding root directory for downloads, imports and moves
</div>
</div>
</div> </div>
<!-- Default Path Customization Section --> <!-- Default Path Customization Section -->

View File

@@ -98,6 +98,14 @@
</div> </div>
</div> </div>
<!-- Top Used Embeddings -->
<div class="list-container">
<h3><i class="fas fa-code"></i> Most Used Embeddings</h3>
<div class="model-list" id="topEmbeddingsList">
<!-- List will be populated by JavaScript -->
</div>
</div>
<!-- Usage Distribution Chart --> <!-- Usage Distribution Chart -->
<div class="chart-container full-width"> <div class="chart-container full-width">
<h3><i class="fas fa-chart-bar"></i> Usage Distribution</h3> <h3><i class="fas fa-chart-bar"></i> Usage Distribution</h3>