diff --git a/py/lora_manager.py b/py/lora_manager.py index 76745943..489b48fe 100644 --- a/py/lora_manager.py +++ b/py/lora_manager.py @@ -6,8 +6,10 @@ from .routes.lora_routes import LoraRoutes from .routes.api_routes import ApiRoutes from .routes.recipe_routes import RecipeRoutes from .services.lora_scanner import LoraScanner +from .services.recipe_scanner import RecipeScanner from .services.file_monitor import LoraFileMonitor from .services.lora_cache import LoraCache +from .services.recipe_cache import RecipeCache import logging logger = logging.getLogger(__name__) @@ -70,24 +72,27 @@ class LoraManager: app['lora_monitor'] = monitor # Schedule cache initialization using the application's startup handler - app.on_startup.append(lambda app: cls._schedule_cache_init(routes.scanner)) + app.on_startup.append(lambda app: cls._schedule_cache_init(routes.scanner, routes.recipe_scanner)) # Add cleanup app.on_shutdown.append(cls._cleanup) app.on_shutdown.append(ApiRoutes.cleanup) @classmethod - async def _schedule_cache_init(cls, scanner: LoraScanner): + async def _schedule_cache_init(cls, scanner: LoraScanner, recipe_scanner: RecipeScanner): """Schedule cache initialization in the running event loop""" try: # 创建低优先级的初始化任务 - asyncio.create_task(cls._initialize_cache(scanner), name='lora_cache_init') + lora_task = asyncio.create_task(cls._initialize_lora_cache(scanner), name='lora_cache_init') + + # Schedule recipe cache initialization with a delay to let lora scanner initialize first + recipe_task = asyncio.create_task(cls._initialize_recipe_cache(recipe_scanner, delay=2), name='recipe_cache_init') except Exception as e: - print(f"LoRA Manager: Error scheduling cache initialization: {e}") + logger.error(f"LoRA Manager: Error scheduling cache initialization: {e}") @classmethod - async def _initialize_cache(cls, scanner: LoraScanner): - """Initialize cache in background""" + async def _initialize_lora_cache(cls, scanner: LoraScanner): + """Initialize lora cache in background""" try: # 设置初始缓存占位 scanner._cache = LoraCache( @@ -100,7 +105,26 @@ class LoraManager: # 分阶段加载缓存 await scanner.get_cached_data(force_refresh=True) except Exception as e: - print(f"LoRA Manager: Error initializing cache: {e}") + logger.error(f"LoRA Manager: Error initializing lora cache: {e}") + + @classmethod + async def _initialize_recipe_cache(cls, scanner: RecipeScanner, delay: float = 2.0): + """Initialize recipe cache in background with a delay""" + try: + # Wait for the specified delay to let lora scanner initialize first + await asyncio.sleep(delay) + + # Set initial empty cache + scanner._cache = RecipeCache( + raw_data=[], + sorted_by_name=[], + sorted_by_date=[] + ) + + # Force refresh to load the actual data + await scanner.get_cached_data(force_refresh=True) + except Exception as e: + logger.error(f"LoRA Manager: Error initializing recipe cache: {e}") @classmethod async def _cleanup(cls, app): diff --git a/py/routes/recipe_routes.py b/py/routes/recipe_routes.py index 99803f59..df1bd2fd 100644 --- a/py/routes/recipe_routes.py +++ b/py/routes/recipe_routes.py @@ -38,6 +38,7 @@ class RecipeRoutes: app.router.add_get('/api/recipe/{recipe_id}', routes.get_recipe_detail) app.router.add_post('/api/recipes/analyze-image', routes.analyze_recipe_image) app.router.add_post('/api/recipes/save', routes.save_recipe) + app.router.add_delete('/api/recipe/{recipe_id}', routes.delete_recipe) # Start cache initialization app.on_startup.append(routes._init_cache) @@ -435,8 +436,13 @@ class RecipeRoutes: json.dump(recipe_data, f, indent=4, ensure_ascii=False) # Add the new recipe directly to the cache instead of forcing a refresh - cache = await self.recipe_scanner.get_cached_data() - await cache.add_recipe(recipe_data) + try: + # Use a timeout to prevent deadlocks + async with asyncio.timeout(5.0): + cache = await self.recipe_scanner.get_cached_data() + await cache.add_recipe(recipe_data) + except asyncio.TimeoutError: + logger.warning("Timeout adding recipe to cache - will be picked up on next refresh") return web.json_response({ 'success': True, @@ -447,4 +453,49 @@ class RecipeRoutes: except Exception as e: logger.error(f"Error saving recipe: {e}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) + + async def delete_recipe(self, request: web.Request) -> web.Response: + """Delete a recipe by ID""" + try: + recipe_id = request.match_info['recipe_id'] + + # Get recipes directory + recipes_dir = self.recipe_scanner.recipes_dir + if not recipes_dir or not os.path.exists(recipes_dir): + return web.json_response({"error": "Recipes directory not found"}, status=404) + + # Find recipe JSON file + recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json") + if not os.path.exists(recipe_json_path): + return web.json_response({"error": "Recipe not found"}, status=404) + + # Load recipe data to get image path + with open(recipe_json_path, 'r', encoding='utf-8') as f: + recipe_data = json.load(f) + + # Get image path + image_path = recipe_data.get('file_path') + + # Delete recipe JSON file + os.remove(recipe_json_path) + logger.info(f"Deleted recipe JSON file: {recipe_json_path}") + + # Delete recipe image if it exists + if image_path and os.path.exists(image_path): + os.remove(image_path) + logger.info(f"Deleted recipe image: {image_path}") + + # Remove from cache without forcing a full refresh + try: + # Use a timeout to prevent deadlocks + async with asyncio.timeout(5.0): + cache = await self.recipe_scanner.get_cached_data(force_refresh=False) + await cache.remove_recipe(recipe_id) + except asyncio.TimeoutError: + logger.warning("Timeout removing recipe from cache - will be picked up on next refresh") + + return web.json_response({"success": True, "message": "Recipe deleted successfully"}) + except Exception as e: + logger.error(f"Error deleting recipe: {e}", exc_info=True) return web.json_response({"error": str(e)}, status=500) \ No newline at end of file diff --git a/py/services/lora_scanner.py b/py/services/lora_scanner.py index 243255d9..0fa98485 100644 --- a/py/services/lora_scanner.py +++ b/py/services/lora_scanner.py @@ -605,7 +605,7 @@ class LoraScanner: # Update hash index with new path if 'sha256' in metadata: - self._hash_index.add_entry(metadata['sha256'], new_path) + self._hash_index.add_entry(metadata['sha256'].lower(), new_path) # Update folders list all_folders = set(item['folder'] for item in cache.raw_data) @@ -658,7 +658,27 @@ class LoraScanner: def get_lora_hash_by_path(self, file_path: str) -> Optional[str]: """Get hash for a LoRA by its file path""" - return self._hash_index.get_hash(file_path) + return self._hash_index.get_hash(file_path) + + def get_preview_url_by_hash(self, sha256: str) -> Optional[str]: + """Get preview static URL for a LoRA by its hash""" + # Get the file path first + file_path = self._hash_index.get_path(sha256.lower()) + if not file_path: + return None + + # Determine the preview file path (typically same name with different extension) + base_name = os.path.splitext(file_path)[0] + preview_extensions = ['.preview.png', '.preview.jpeg', '.preview.jpg', '.preview.mp4', + '.png', '.jpeg', '.jpg', '.mp4'] + + for ext in preview_extensions: + preview_path = f"{base_name}{ext}" + if os.path.exists(preview_path): + # Convert to static URL using config + return config.get_preview_static_url(preview_path) + + return None # Add new method to get top tags async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]: diff --git a/py/services/recipe_cache.py b/py/services/recipe_cache.py index 87446677..b9cd2219 100644 --- a/py/services/recipe_cache.py +++ b/py/services/recipe_cache.py @@ -58,4 +58,28 @@ class RecipeCache: """ async with self._lock: self.raw_data.append(recipe_data) - await self.resort() \ No newline at end of file + await self.resort() + + async def remove_recipe(self, recipe_id: str) -> bool: + """Remove a recipe from the cache by ID + + Args: + recipe_id: The ID of the recipe to remove + + Returns: + bool: True if the recipe was found and removed, False otherwise + """ + # Find the recipe in raw_data + recipe_index = next((i for i, recipe in enumerate(self.raw_data) + if recipe.get('id') == recipe_id), None) + + if recipe_index is None: + return False + + # Remove from raw_data + self.raw_data.pop(recipe_index) + + # Resort to update sorted lists + await self.resort() + + return True \ No newline at end of file diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index e2145418..728c2038 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -32,9 +32,12 @@ class RecipeScanner: self._cache: Optional[RecipeCache] = None self._initialization_lock = asyncio.Lock() self._initialization_task: Optional[asyncio.Task] = None + self._is_initializing = False if lora_scanner: self._lora_scanner = lora_scanner self._initialized = True + + # Initialization will be scheduled by LoraManager @property def recipes_dir(self) -> str: @@ -51,86 +54,79 @@ class RecipeScanner: async def get_cached_data(self, force_refresh: bool = False) -> RecipeCache: """Get cached recipe data, refresh if needed""" - async with self._initialization_lock: - - # If cache is unitialized but needs to respond to request, return empty cache - if self._cache is None and not force_refresh: - return RecipeCache( - raw_data=[], - sorted_by_name=[], - sorted_by_date=[] - ) - - # If initializing, wait for completion - if self._initialization_task and not self._initialization_task.done(): - try: - await self._initialization_task - except Exception as e: - logger.error(f"Recipe cache initialization failed: {e}") - self._initialization_task = None - - if (self._cache is None or force_refresh): - - # Create new initialization task - if not self._initialization_task or self._initialization_task.done(): - # First ensure the lora scanner is initialized - if self._lora_scanner: - await self._lora_scanner.get_cached_data() - - self._initialization_task = asyncio.create_task(self._initialize_cache()) - - try: - await self._initialization_task - except Exception as e: - logger.error(f"Recipe cache initialization failed: {e}") - # If cache already exists, continue using old cache - if self._cache is None: - raise # If no cache, raise exception - + # If cache is already initialized and no refresh is needed, return it immediately + if self._cache is not None and not force_refresh: return self._cache - - async def _initialize_cache(self) -> None: - """Initialize or refresh the cache""" - try: - # Ensure lora scanner is fully initialized first - if self._lora_scanner: - logger.info("Recipe Manager: Waiting for lora scanner initialization to complete") - - # Get the lora cache to ensure it's initialized - lora_cache = await self._lora_scanner.get_cached_data() - logger.info(f"Recipe Manager: Lora scanner initialized with {len(lora_cache.raw_data)} loras") - - # Verify hash index is built - if hasattr(self._lora_scanner, '_hash_index'): - hash_index_size = len(self._lora_scanner._hash_index._hash_to_path) if hasattr(self._lora_scanner._hash_index, '_hash_to_path') else 0 - logger.info(f"Recipe Manager: Lora hash index contains {hash_index_size} entries") - else: - logger.warning("Recipe Manager: No lora hash index available") - else: - logger.warning("Recipe Manager: No lora scanner available") - - # Scan for recipe data - raw_data = await self.scan_all_recipes() - - # Update cache - self._cache = RecipeCache( - raw_data=raw_data, - sorted_by_name=[], - sorted_by_date=[] - ) - - # Resort cache - await self._cache.resort() - self._initialization_task = None - logger.info("Recipe Manager: Cache initialization completed") + # If another initialization is already in progress, wait for it to complete + if self._is_initializing and not force_refresh: + logger.info("Initialization already in progress, returning current cache state") + return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[]) + + # Try to acquire the lock with a timeout to prevent deadlocks + try: + # Use a timeout for acquiring the lock + async with asyncio.timeout(1.0): + async with self._initialization_lock: + # Check again after acquiring the lock + if self._cache is not None and not force_refresh: + return self._cache + + # Mark as initializing to prevent concurrent initializations + self._is_initializing = True + + try: + # First ensure the lora scanner is initialized + if self._lora_scanner: + try: + logger.info("Recipe Manager: Waiting for lora scanner initialization") + lora_cache = await asyncio.wait_for( + self._lora_scanner.get_cached_data(), + timeout=10.0 + ) + logger.info(f"Recipe Manager: Lora scanner initialized with {len(lora_cache.raw_data)} loras") + except asyncio.TimeoutError: + logger.error("Timeout waiting for lora scanner initialization") + except Exception as e: + logger.error(f"Error waiting for lora scanner: {e}") + + # Scan for recipe data + logger.info("Recipe Manager: Starting recipe scan") + raw_data = await self.scan_all_recipes() + + # Update cache + self._cache = RecipeCache( + raw_data=raw_data, + sorted_by_name=[], + sorted_by_date=[] + ) + + # Resort cache + await self._cache.resort() + + logger.info(f"Recipe Manager: Cache initialization completed with {len(raw_data)} recipes") + return self._cache + + except Exception as e: + logger.error(f"Recipe Manager: Error initializing cache: {e}", exc_info=True) + # Create empty cache on error + self._cache = RecipeCache( + raw_data=[], + sorted_by_name=[], + sorted_by_date=[] + ) + return self._cache + finally: + # Mark initialization as complete + self._is_initializing = False + + except asyncio.TimeoutError: + # If we can't acquire the lock in time, return the current cache or an empty one + logger.warning("Timeout acquiring initialization lock - returning current cache state") + return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[]) except Exception as e: - logger.error(f"Recipe Manager: Error initializing cache: {e}", exc_info=True) - self._cache = RecipeCache( - raw_data=[], - sorted_by_name=[], - sorted_by_date=[] - ) + logger.error(f"Unexpected error in get_cached_data: {e}") + return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[]) async def scan_all_recipes(self) -> List[Dict]: """Scan all recipe JSON files and return metadata""" @@ -215,35 +211,6 @@ class RecipeScanner: traceback.print_exc(file=sys.stderr) return None - def _create_basic_recipe_data(self, image_path: str) -> Dict: - """Create basic recipe data from file information""" - file_name = os.path.basename(image_path) - title = os.path.splitext(file_name)[0] - - return { - 'file_path': image_path.replace(os.sep, '/'), - 'title': title, - 'file_name': file_name, - 'modified': os.path.getmtime(image_path), - 'created_date': os.path.getctime(image_path), - 'loras': [] - } - - def _extract_created_date(self, user_comment: str) -> Optional[float]: - """Extract creation date from UserComment if present""" - try: - # Look for Created Date pattern - created_date_match = re.search(r'Created Date: ([^,}]+)', user_comment) - if created_date_match: - date_str = created_date_match.group(1).strip() - # Parse ISO format date - dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) - return dt.timestamp() - except Exception as e: - logger.error(f"Error extracting creation date: {e}") - - return None - async def _update_lora_information(self, recipe_data: Dict) -> bool: """Update LoRA information with hash and file_name @@ -510,6 +477,7 @@ class RecipeScanner: for lora in item['loras']: if 'hash' in lora and lora['hash']: lora['inLibrary'] = self._lora_scanner.has_lora_hash(lora['hash'].lower()) + lora['preview_url'] = self._lora_scanner.get_preview_url_by_hash(lora['hash'].lower()) result = { 'items': paginated_items, diff --git a/static/css/components/recipe-card.css b/static/css/components/recipe-card.css index 7acfb70d..ef821cb8 100644 --- a/static/css/components/recipe-card.css +++ b/static/css/components/recipe-card.css @@ -1,20 +1,40 @@ -.recipe-card { - background: var(--lora-surface); - border: 1px solid var(--lora-border); - border-radius: var(--border-radius-base); - backdrop-filter: blur(16px); - transition: transform 160ms ease-out; - aspect-ratio: 896/1152; - max-width: 260px; - margin: 0 auto; - position: relative; - overflow: hidden; +.recipe-tag-container { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.recipe-tag { + background: var(--lora-surface-hover); + color: var(--lora-text-secondary); + padding: 0.25rem 0.5rem; + border-radius: var(--border-radius-sm); + font-size: 0.8rem; cursor: pointer; + transition: all 0.2s ease; +} + +.recipe-tag:hover, .recipe-tag.active { + background: var(--lora-primary); + color: var(--lora-text-on-primary); +} + +.recipe-card { + position: relative; + background: var(--lora-surface); + border-radius: var(--border-radius-base); + overflow: hidden; + box-shadow: var(--shadow-sm); + transition: all 0.2s ease; + cursor: pointer; + display: flex; + flex-direction: column; } .recipe-card:hover { - transform: translateY(-2px); - background: oklch(100% 0 0 / 0.6); + transform: translateY(-3px); + box-shadow: var(--shadow-md); } .recipe-card:focus-visible { @@ -28,7 +48,7 @@ left: 8px; width: 24px; height: 24px; - background: var(--lora-accent); + background: var(--lora-primary); border-radius: 50%; display: flex; align-items: center; @@ -36,20 +56,21 @@ color: white; font-weight: bold; z-index: 2; - font-size: 0.9em; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); } .recipe-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - gap: 12px; - margin-top: var(--space-2); - padding-top: 4px; - padding-bottom: 4px; - max-width: 1400px; - margin-left: auto; - margin-right: auto; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1.5rem; + margin-top: 1.5rem; +} + +.placeholder-message { + grid-column: 1 / -1; + text-align: center; + padding: 2rem; + background: var(--lora-surface-alt); + border-radius: var(--border-radius-base); } .card-preview { @@ -129,6 +150,15 @@ padding: 2px 8px; border-radius: var(--border-radius-xs); font-size: 0.85em; + position: relative; +} + +.lora-count.ready { + background: rgba(46, 204, 113, 0.3); +} + +.lora-count.missing { + background: rgba(231, 76, 60, 0.3); } /* 响应式设计 */ diff --git a/static/css/components/recipe-modal.css b/static/css/components/recipe-modal.css new file mode 100644 index 00000000..645b3f1f --- /dev/null +++ b/static/css/components/recipe-modal.css @@ -0,0 +1,367 @@ +.recipe-modal-header { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + border-bottom: 1px solid var(--lora-border); +} + +/* Top Section: Preview and Gen Params */ +.recipe-top-section { + display: grid; + grid-template-columns: 280px 1fr; + gap: var(--space-2); + flex-shrink: 0; + margin-bottom: var(--space-2); +} + +/* Recipe Preview */ +.recipe-preview-container { + width: 100%; + height: 360px; + border-radius: var(--border-radius-sm); + overflow: hidden; + background: var(--lora-surface); + border: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: center; +} + +.recipe-preview-container img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +/* Generation Parameters */ +.recipe-gen-params { + height: 360px; + display: flex; + flex-direction: column; +} + +.recipe-gen-params h3 { + margin-top: 0; + margin-bottom: var(--space-2); + font-size: 1.2em; + color: var(--text-color); + padding-bottom: var(--space-1); + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.gen-params-container { + display: flex; + flex-direction: column; + gap: var(--space-2); + overflow-y: auto; + flex: 1; +} + +.param-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.param-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.param-header label { + font-weight: 500; + color: var(--text-color); +} + +.copy-btn { + background: none; + border: none; + color: var(--text-color); + opacity: 0.6; + cursor: pointer; + padding: 4px 8px; + border-radius: var(--border-radius-xs); + transition: all 0.2s; +} + +.copy-btn:hover { + opacity: 1; + background: var(--lora-surface); +} + +.param-content { + background: var(--lora-surface); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + padding: var(--space-2); + color: var(--text-color); + font-size: 0.9em; + line-height: 1.5; + max-height: 150px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; +} + +/* Other Parameters */ +.other-params { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: var(--space-1); +} + +.param-tag { + background: var(--lora-surface); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + padding: 4px 8px; + font-size: 0.85em; + color: var(--text-color); + display: flex; + align-items: center; + gap: 6px; +} + +.param-tag .param-name { + font-weight: 500; + opacity: 0.8; +} + +/* Bottom Section: Resources */ +.recipe-bottom-section { + max-height: 340px; + display: flex; + flex-direction: column; + border-top: 1px solid var(--border-color); + padding-top: var(--space-2); +} + +.recipe-section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-2); + padding-bottom: var(--space-1); + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.recipe-section-header h3 { + margin: 0; + font-size: 1.2em; + color: var(--text-color); + display: flex; + align-items: center; + gap: 8px; +} + +.recipe-status { + display: inline-flex; + align-items: center; + font-size: 0.85em; + padding: 4px 8px; + border-radius: var(--border-radius-xs); + margin-left: var(--space-1); +} + +.recipe-status.ready { + background: oklch(var(--lora-accent) / 0.1); + color: var(--lora-accent); +} + +.recipe-status.missing { + background: oklch(var(--lora-error) / 0.1); + color: var(--lora-error); +} + +.recipe-status i { + margin-right: 4px; +} + +.recipe-section-actions { + display: flex; + align-items: center; + gap: var(--space-1); +} + +#recipeLorasCount { + font-size: 0.9em; + color: var(--text-color); + opacity: 0.8; + display: flex; + align-items: center; + gap: 6px; +} + +#recipeLorasCount i { + font-size: 1em; +} + +/* LoRAs List */ +.recipe-loras-list { + display: flex; + flex-direction: column; + gap: 12px; + overflow-y: auto; + flex: 1; +} + +.recipe-lora-item { + display: flex; + gap: var(--space-2); + padding: var(--space-2); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + background: var(--bg-color); +} + +.recipe-lora-item.exists-locally { + background: oklch(var(--lora-accent) / 0.05); + border-left: 4px solid var(--lora-accent); +} + +.recipe-lora-item.missing-locally { + border-left: 4px solid var(--lora-error); +} + +.recipe-lora-thumbnail { + width: 50px; + height: 50px; + flex-shrink: 0; + border-radius: var(--border-radius-xs); + overflow: hidden; + background: var(--bg-color); +} + +.recipe-lora-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.recipe-lora-content { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; +} + +.recipe-lora-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-2); +} + +.recipe-lora-content h4 { + margin: 0; + font-size: 1em; + color: var(--text-color); + flex: 1; +} + +.recipe-lora-info { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + font-size: 0.85em; +} + +.recipe-lora-info .base-model { + background: oklch(var(--lora-accent) / 0.1); + color: var(--lora-accent); + padding: 2px 8px; + border-radius: var(--border-radius-xs); +} + +.recipe-lora-version { + font-size: 0.85em; + color: var(--text-color); + opacity: 0.7; +} + +.recipe-lora-weight { + background: var(--lora-surface); + padding: 2px 8px; + border-radius: var(--border-radius-xs); + font-size: 0.85em; +} + +.local-badge { + display: inline-flex; + align-items: center; + background: var(--lora-accent); + color: white; + padding: 3px 6px; + border-radius: var(--border-radius-xs); + font-size: 0.75em; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; + position: relative; +} + +.local-badge i { + margin-right: 4px; + font-size: 0.9em; +} + +.missing-badge { + display: inline-flex; + align-items: center; + background: var(--lora-error); + color: white; + padding: 3px 6px; + border-radius: var(--border-radius-xs); + font-size: 0.75em; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; +} + +.missing-badge i { + margin-right: 4px; + font-size: 0.9em; +} + +.local-path { + display: none; + position: absolute; + background: var(--bg-color); + border: 1px solid var(--border-color); + padding: 4px 8px; + border-radius: var(--border-radius-xs); + font-size: 0.85em; + z-index: 10; + top: 100%; + left: 0; + margin-top: 4px; + white-space: normal; + max-width: 250px; +} + +.local-badge:hover .local-path { + display: block; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .recipe-top-section { + grid-template-columns: 1fr; + } + + .recipe-preview-container { + height: 200px; + } + + .recipe-gen-params { + height: auto; + max-height: 300px; + } +} diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js new file mode 100644 index 00000000..f745f3ad --- /dev/null +++ b/static/js/components/RecipeCard.js @@ -0,0 +1,230 @@ +// Recipe Card Component +import { showToast } from '../utils/uiHelpers.js'; + +class RecipeCard { + constructor(recipe, clickHandler) { + this.recipe = recipe; + this.clickHandler = clickHandler; + this.element = this.createCardElement(); + } + + createCardElement() { + const card = document.createElement('div'); + card.className = 'recipe-card'; + card.dataset.filePath = this.recipe.file_path; + card.dataset.title = this.recipe.title; + card.dataset.created = this.recipe.created_date; + card.dataset.id = this.recipe.id || ''; + + // Get base model + const baseModel = this.recipe.base_model || ''; + + // Ensure loras array exists + const loras = this.recipe.loras || []; + const lorasCount = loras.length; + + // Check if all LoRAs are available in the library + const missingLorasCount = loras.filter(lora => !lora.inLibrary).length; + const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0; + + // Ensure file_url exists, fallback to file_path if needed + const imageUrl = this.recipe.file_url || + (this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` : + '/loras_static/images/no-preview.png'); + + card.innerHTML = ` +
+ This action cannot be undone.
+
+