mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-23 06:02:11 -03:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d89c2ca128 | ||
|
|
835584cc85 | ||
|
|
b2ffbe3a68 | ||
|
|
defcc79e6c | ||
|
|
c06d9f84f0 | ||
|
|
fe57a8e156 | ||
|
|
b77105795a | ||
|
|
e2df5fcf27 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]]:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
245
static/css/components/banner.css
Normal file
245
static/css/components/banner.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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}">`
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
176
static/js/managers/BannerService.js
Normal file
176
static/js/managers/BannerService.js
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' %}
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user