From 406284a04516e07d4946d6e2cd233af99a4d1352 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Sun, 16 Mar 2025 16:56:33 +0800 Subject: [PATCH] checkpoint --- py/lora_manager.py | 38 ++- py/routes/recipe_routes.py | 55 +++- py/services/lora_scanner.py | 24 +- py/services/recipe_cache.py | 26 +- py/services/recipe_scanner.py | 180 +++++------- static/css/components/recipe-card.css | 78 ++++-- static/css/components/recipe-modal.css | 367 +++++++++++++++++++++++++ static/js/components/RecipeCard.js | 230 ++++++++++++++++ static/js/components/RecipeModal.js | 205 ++++++++++++++ static/js/managers/ImportManager.js | 2 + static/js/managers/ModalManager.js | 9 + static/js/recipes.js | 93 +------ templates/components/recipe_modal.html | 63 +++++ templates/recipes.html | 75 +---- 14 files changed, 1145 insertions(+), 300 deletions(-) create mode 100644 static/css/components/recipe-modal.css create mode 100644 static/js/components/RecipeCard.js create mode 100644 static/js/components/RecipeModal.js create mode 100644 templates/components/recipe_modal.html 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 = ` +
R
+
+ ${this.recipe.title} +
+
+ ${baseModel ? `${baseModel}` : ''} +
+
+ + + +
+
+ +
+ `; + + this.attachEventListeners(card); + return card; + } + + getLoraStatusTitle(totalCount, missingCount) { + if (totalCount === 0) return "No LoRAs in this recipe"; + if (missingCount === 0) return "All LoRAs available - Ready to use"; + return `${missingCount} of ${totalCount} LoRAs missing`; + } + + attachEventListeners(card) { + // Recipe card click event + card.addEventListener('click', () => { + this.clickHandler(this.recipe); + }); + + // Share button click event - prevent propagation to card + card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => { + e.stopPropagation(); + // TODO: Implement share functionality + showToast('Share functionality will be implemented later', 'info'); + }); + + // Copy button click event - prevent propagation to card + card.querySelector('.fa-copy')?.addEventListener('click', (e) => { + e.stopPropagation(); + this.copyRecipeSyntax(); + }); + + // Delete button click event - prevent propagation to card + card.querySelector('.fa-trash')?.addEventListener('click', (e) => { + e.stopPropagation(); + this.showDeleteConfirmation(); + }); + } + + copyRecipeSyntax() { + try { + // Generate recipe syntax in the format separated by spaces + const loras = this.recipe.loras || []; + if (loras.length === 0) { + showToast('No LoRAs in this recipe to copy', 'warning'); + return; + } + + const syntax = loras.map(lora => { + // Use file_name if available, otherwise use empty placeholder + const fileName = lora.file_name || '[missing-lora]'; + const strength = lora.strength || 1.0; + return ``; + }).join(' '); + + // Copy to clipboard + navigator.clipboard.writeText(syntax) + .then(() => { + showToast('Recipe syntax copied to clipboard', 'success'); + }) + .catch(err => { + console.error('Failed to copy: ', err); + showToast('Failed to copy recipe syntax', 'error'); + }); + } catch (error) { + console.error('Error copying recipe syntax:', error); + showToast('Error copying recipe syntax', 'error'); + } + } + + showDeleteConfirmation() { + try { + // Get recipe ID + const recipeId = this.recipe.id; + if (!recipeId) { + showToast('Cannot delete recipe: Missing recipe ID', 'error'); + return; + } + + // Set up delete modal content + const deleteModal = document.getElementById('deleteModal'); + const deleteMessage = deleteModal.querySelector('.delete-message'); + const deleteModelInfo = deleteModal.querySelector('.delete-model-info'); + + // Update modal content + deleteMessage.textContent = 'Are you sure you want to delete this recipe?'; + deleteModelInfo.innerHTML = ` +
+ ${this.recipe.title} +
+
+

${this.recipe.title}

+

This action cannot be undone.

+
+ `; + + // Store recipe ID in the modal for the delete confirmation handler + deleteModal.dataset.recipeId = recipeId; + + // Update the confirm delete button to use recipe delete handler + const deleteBtn = deleteModal.querySelector('.delete-btn'); + deleteBtn.onclick = () => this.confirmDeleteRecipe(); + + // Show the modal + deleteModal.style.display = 'flex'; + } catch (error) { + console.error('Error showing delete confirmation:', error); + showToast('Error showing delete confirmation', 'error'); + } + } + + confirmDeleteRecipe() { + const deleteModal = document.getElementById('deleteModal'); + const recipeId = deleteModal.dataset.recipeId; + + if (!recipeId) { + showToast('Cannot delete recipe: Missing recipe ID', 'error'); + closeDeleteModal(); + return; + } + + // Show loading state + const deleteBtn = deleteModal.querySelector('.delete-btn'); + const originalText = deleteBtn.textContent; + deleteBtn.textContent = 'Deleting...'; + deleteBtn.disabled = true; + + // Call API to delete the recipe + fetch(`/api/recipe/${recipeId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + } + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to delete recipe'); + } + return response.json(); + }) + .then(data => { + showToast('Recipe deleted successfully', 'success'); + + // Refresh the recipe list if we're on the recipes page + if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') { + window.recipeManager.loadRecipes(); + } + + closeDeleteModal(); + }) + .catch(error => { + console.error('Error deleting recipe:', error); + showToast('Error deleting recipe: ' + error.message, 'error'); + + // Reset button state + deleteBtn.textContent = originalText; + deleteBtn.disabled = false; + }); + } + + closeDeleteModal() { + const deleteModal = document.getElementById('deleteModal'); + deleteModal.style.display = 'none'; + + // Reset the delete button handler + const deleteBtn = deleteModal.querySelector('.delete-btn'); + deleteBtn.textContent = 'Delete'; + deleteBtn.disabled = false; + deleteBtn.onclick = null; + } +} + +export { RecipeCard }; \ No newline at end of file diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js new file mode 100644 index 00000000..37d68a2a --- /dev/null +++ b/static/js/components/RecipeModal.js @@ -0,0 +1,205 @@ +// Recipe Modal Component +import { showToast } from '../utils/uiHelpers.js'; + +class RecipeModal { + constructor() { + this.init(); + } + + init() { + this.setupCopyButtons(); + } + + showRecipeDetails(recipe) { + console.log(recipe); + // Set modal title + const modalTitle = document.getElementById('recipeModalTitle'); + if (modalTitle) { + modalTitle.textContent = recipe.title || 'Recipe Details'; + } + + // Set recipe image + const modalImage = document.getElementById('recipeModalImage'); + if (modalImage) { + // Ensure file_url exists, fallback to file_path if needed + const imageUrl = recipe.file_url || + (recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` : + '/loras_static/images/no-preview.png'); + modalImage.src = imageUrl; + modalImage.alt = recipe.title || 'Recipe Preview'; + } + + // Set generation parameters + const promptElement = document.getElementById('recipePrompt'); + const negativePromptElement = document.getElementById('recipeNegativePrompt'); + const otherParamsElement = document.getElementById('recipeOtherParams'); + + if (recipe.gen_params) { + // Set prompt + if (promptElement && recipe.gen_params.prompt) { + promptElement.textContent = recipe.gen_params.prompt; + } else if (promptElement) { + promptElement.textContent = 'No prompt information available'; + } + + // Set negative prompt + if (negativePromptElement && recipe.gen_params.negative_prompt) { + negativePromptElement.textContent = recipe.gen_params.negative_prompt; + } else if (negativePromptElement) { + negativePromptElement.textContent = 'No negative prompt information available'; + } + + // Set other parameters + if (otherParamsElement) { + // Clear previous params + otherParamsElement.innerHTML = ''; + + // Add all other parameters except prompt and negative_prompt + const excludedParams = ['prompt', 'negative_prompt']; + + for (const [key, value] of Object.entries(recipe.gen_params)) { + if (!excludedParams.includes(key) && value !== undefined && value !== null) { + const paramTag = document.createElement('div'); + paramTag.className = 'param-tag'; + paramTag.innerHTML = ` + ${key}: + ${value} + `; + otherParamsElement.appendChild(paramTag); + } + } + + // If no other params, show a message + if (otherParamsElement.children.length === 0) { + otherParamsElement.innerHTML = '
No additional parameters available
'; + } + } + } else { + // No generation parameters available + if (promptElement) promptElement.textContent = 'No prompt information available'; + if (negativePromptElement) negativePromptElement.textContent = 'No negative prompt information available'; + if (otherParamsElement) otherParamsElement.innerHTML = '
No parameters available
'; + } + + // Set LoRAs list and count + const lorasListElement = document.getElementById('recipeLorasList'); + const lorasCountElement = document.getElementById('recipeLorasCount'); + + // 检查所有 LoRAs 是否都在库中 + let allLorasAvailable = true; + let missingLorasCount = 0; + + if (recipe.loras && recipe.loras.length > 0) { + recipe.loras.forEach(lora => { + if (!lora.inLibrary) { + allLorasAvailable = false; + missingLorasCount++; + } + }); + } + + // 设置 LoRAs 计数和状态 + if (lorasCountElement && recipe.loras) { + const totalCount = recipe.loras.length; + + // 创建状态指示器 + let statusHTML = ''; + if (totalCount > 0) { + if (allLorasAvailable) { + statusHTML = `
Ready to use
`; + } else { + statusHTML = `
${missingLorasCount} missing
`; + } + } + + lorasCountElement.innerHTML = ` ${totalCount} LoRAs ${statusHTML}`; + } + + if (lorasListElement && recipe.loras && recipe.loras.length > 0) { + lorasListElement.innerHTML = recipe.loras.map(lora => { + const existsLocally = lora.inLibrary; + const localPath = lora.localPath || ''; + + // Create local status badge + const localStatus = existsLocally ? + `
+ In Library +
${localPath}
+
` : + `
+ Not in Library +
`; + + return ` +
+
+ LoRA preview +
+
+
+

${lora.modelName}

+ ${localStatus} +
+ ${lora.modelVersionName ? `
${lora.modelVersionName}
` : ''} +
+ ${lora.baseModel ? `
${lora.baseModel}
` : ''} +
Weight: ${lora.strength || 1.0}
+
+
+
+ `; + }).join(''); + + // Generate recipe syntax for copy button + this.recipeLorasSyntax = recipe.loras.map(lora => + `` + ).join(' '); + + } else if (lorasListElement) { + lorasListElement.innerHTML = '
No LoRAs associated with this recipe
'; + this.recipeLorasSyntax = ''; + } + + // Show the modal + modalManager.showModal('recipeModal'); + } + + // Setup copy buttons for prompts and recipe syntax + setupCopyButtons() { + const copyPromptBtn = document.getElementById('copyPromptBtn'); + const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn'); + const copyRecipeSyntaxBtn = document.getElementById('copyRecipeSyntaxBtn'); + + if (copyPromptBtn) { + copyPromptBtn.addEventListener('click', () => { + const promptText = document.getElementById('recipePrompt').textContent; + this.copyToClipboard(promptText, 'Prompt copied to clipboard'); + }); + } + + if (copyNegativePromptBtn) { + copyNegativePromptBtn.addEventListener('click', () => { + const negativePromptText = document.getElementById('recipeNegativePrompt').textContent; + this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard'); + }); + } + + if (copyRecipeSyntaxBtn) { + copyRecipeSyntaxBtn.addEventListener('click', () => { + this.copyToClipboard(this.recipeLorasSyntax, 'Recipe syntax copied to clipboard'); + }); + } + } + + // Helper method to copy text to clipboard + copyToClipboard(text, successMessage) { + navigator.clipboard.writeText(text).then(() => { + showToast(successMessage, 'success'); + }).catch(err => { + console.error('Failed to copy text: ', err); + showToast('Failed to copy text', 'error'); + }); + } +} + +export { RecipeModal }; \ No newline at end of file diff --git a/static/js/managers/ImportManager.js b/static/js/managers/ImportManager.js index be941af0..bfcb0911 100644 --- a/static/js/managers/ImportManager.js +++ b/static/js/managers/ImportManager.js @@ -175,6 +175,8 @@ export class ImportManager { // Get recipe data from response this.recipeData = await response.json(); + + console.log('Recipe data:', this.recipeData); // Check if we have an error message if (this.recipeData.error) { diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index c6bcf3ad..2c7dfbf0 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -81,6 +81,15 @@ export class ModalManager { } }); + // Add recipeModal registration + this.registerModal('recipeModal', { + element: document.getElementById('recipeModal'), + onClose: () => { + this.getModal('recipeModal').element.style.display = 'none'; + document.body.classList.remove('modal-open'); + } + }); + // Set up event listeners for modal toggles const supportToggle = document.getElementById('supportToggleBtn'); if (supportToggle) { diff --git a/static/js/recipes.js b/static/js/recipes.js index 3be9e683..19eb10e1 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -3,6 +3,8 @@ import { showToast } from './utils/uiHelpers.js'; import { state } from './state/index.js'; import { initializeCommonComponents } from './common.js'; import { ImportManager } from './managers/ImportManager.js'; +import { RecipeCard } from './components/RecipeCard.js'; +import { RecipeModal } from './components/RecipeModal.js'; class RecipeManager { constructor() { @@ -14,6 +16,9 @@ class RecipeManager { // Initialize ImportManager this.importManager = new ImportManager(); + // Initialize RecipeModal + this.recipeModal = new RecipeModal(); + this.init(); } @@ -121,97 +126,15 @@ class RecipeManager { // Create recipe cards data.items.forEach(recipe => { - const card = this.createRecipeCard(recipe); - grid.appendChild(card); + const recipeCard = new RecipeCard(recipe, (recipe) => this.showRecipeDetails(recipe)); + grid.appendChild(recipeCard.element); }); } - createRecipeCard(recipe) { - const card = document.createElement('div'); - card.className = 'recipe-card'; - card.dataset.filePath = recipe.file_path; - card.dataset.title = recipe.title; - card.dataset.created = recipe.created_date; - - console.log(recipe); - - // 获取 base model - const baseModel = recipe.base_model || ''; - - // 确保 loras 数组存在 - const lorasCount = recipe.loras ? recipe.loras.length : 0; - - // Ensure file_url exists, fallback to file_path if needed - const imageUrl = recipe.file_url || - (recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` : - '/loras_static/images/no-preview.png'); - - card.innerHTML = ` -
R
-
- ${recipe.title} -
-
- ${baseModel ? `${baseModel}` : ''} -
-
- - - -
-
- -
- `; - - // Recipe card click event - card.addEventListener('click', () => { - this.showRecipeDetails(recipe); - }); - - // Share button click event - prevent propagation to card - card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => { - e.stopPropagation(); - // TODO: Implement share functionality - showToast('Share functionality will be implemented later', 'info'); - }); - - // Copy button click event - prevent propagation to card - card.querySelector('.fa-copy')?.addEventListener('click', (e) => { - e.stopPropagation(); - // TODO: Implement copy functionality - showToast('Copy functionality will be implemented later', 'info'); - }); - - // Delete button click event - prevent propagation to card - card.querySelector('.fa-trash')?.addEventListener('click', (e) => { - e.stopPropagation(); - // TODO: Implement delete functionality - showToast('Delete functionality will be implemented later', 'info'); - }); - - return card; - } - - // Add a placeholder for recipe details method showRecipeDetails(recipe) { - // TODO: Implement recipe details view - console.log('Recipe details:', recipe); - showToast(`Viewing ${recipe.title}`, 'info'); + this.recipeModal.showRecipeDetails(recipe); } - // Will be implemented later: - // - Recipe details view - // - Recipe tag filtering - // - Recipe search and filters - // Add a method to handle recipe import importRecipes() { this.importManager.showImportModal(); diff --git a/templates/components/recipe_modal.html b/templates/components/recipe_modal.html new file mode 100644 index 00000000..06302449 --- /dev/null +++ b/templates/components/recipe_modal.html @@ -0,0 +1,63 @@ + diff --git a/templates/recipes.html b/templates/recipes.html index b4935984..f5f93126 100644 --- a/templates/recipes.html +++ b/templates/recipes.html @@ -5,6 +5,7 @@ + @@ -33,79 +34,6 @@ - - {% include 'components/header.html' %} @@ -115,6 +43,7 @@ {% include 'components/loading.html' %} {% include 'components/context_menu.html' %} {% include 'components/import_modal.html' %} + {% include 'components/recipe_modal.html' %}
{% if is_initializing %}