diff --git a/py/routes/recipe_routes.py b/py/routes/recipe_routes.py index 4f01957d..2c8cb127 100644 --- a/py/routes/recipe_routes.py +++ b/py/routes/recipe_routes.py @@ -3,10 +3,17 @@ import logging import sys from aiohttp import web from typing import Dict +import tempfile +import json +import aiohttp +import asyncio +from ..utils.exif_utils import ExifUtils +from ..services.civitai_client import CivitaiClient from ..services.recipe_scanner import RecipeScanner from ..services.lora_scanner import LoraScanner from ..config import config +import time # Add this import at the top logger = logging.getLogger(__name__) print("Recipe Routes module loaded", file=sys.stderr) @@ -17,6 +24,7 @@ class RecipeRoutes: def __init__(self): print("Initializing RecipeRoutes", file=sys.stderr) self.recipe_scanner = RecipeScanner(LoraScanner()) + self.civitai_client = CivitaiClient() # Pre-warm the cache self._init_cache_task = None @@ -28,6 +36,9 @@ class RecipeRoutes: routes = cls() app.router.add_get('/api/recipes', routes.get_recipes) 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/download-missing-loras', routes.download_missing_loras) + app.router.add_post('/api/recipes/save', routes.save_recipe) # Start cache initialization app.on_startup.append(routes._init_cache) @@ -143,4 +154,295 @@ class RecipeRoutes: def _format_timestamp(self, timestamp: float) -> str: """Format timestamp for display""" from datetime import datetime - return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S') \ No newline at end of file + return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S') + + async def analyze_recipe_image(self, request: web.Request) -> web.Response: + """Analyze an uploaded image for recipe metadata""" + temp_path = None + try: + reader = await request.multipart() + field = await reader.next() + + if field.name != 'image': + return web.json_response({ + "error": "No image field found", + "loras": [] + }, status=400) + + # Create a temporary file to store the uploaded image + with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file: + while True: + chunk = await field.read_chunk() + if not chunk: + break + temp_file.write(chunk) + temp_path = temp_file.name + + # Extract metadata from the image using ExifUtils + user_comment = ExifUtils.extract_user_comment(temp_path) + print(f"User comment: {user_comment}", file=sys.stderr) + + # If no metadata found, return a more specific error + if not user_comment: + return web.json_response({ + "error": "No metadata found in this image", + "loras": [] # Return empty loras array to prevent client-side errors + }, status=200) # Return 200 instead of 400 to handle gracefully + + # Parse the recipe metadata + metadata = ExifUtils.parse_recipe_metadata(user_comment) + print(f"Metadata: {metadata}", file=sys.stderr) + + # Look for Civitai resources in the metadata + civitai_resources = metadata.get('loras', []) + checkpoint = metadata.get('checkpoint') + + if not civitai_resources and not checkpoint: + return web.json_response({ + "error": "No LoRA information found in this image", + "loras": [] # Return empty loras array + }, status=200) # Return 200 instead of 400 + + # Process the resources to get LoRA information + loras = [] + base_model = None + + # Set base model from checkpoint if available + if checkpoint: + base_model = checkpoint.get('modelName', '') + + # Process LoRAs + for resource in civitai_resources: + # Get model version ID + model_version_id = resource.get('modelVersionId') + if not model_version_id: + continue + + # Get additional info from Civitai + civitai_info = await self.civitai_client.get_model_version_info(model_version_id) + print(f"Civitai info: {civitai_info}", file=sys.stderr) + + # Check if this LoRA exists locally by SHA256 hash + exists_locally = False + local_path = "" + + if civitai_info and 'files' in civitai_info and civitai_info['files']: + sha256 = civitai_info['files'][0].get('hashes', {}).get('SHA256', '') + if sha256: + sha256 = sha256.lower() # Convert to lowercase for consistency + exists_locally = self.recipe_scanner._lora_scanner.has_lora_hash(sha256) + if exists_locally: + local_path = self.recipe_scanner._lora_scanner.get_lora_path_by_hash(sha256) or "" + + # Create LoRA entry + lora_entry = { + 'id': model_version_id, + 'name': resource.get('modelName', ''), + 'version': resource.get('modelVersionName', ''), + 'type': resource.get('type', 'lora'), + 'weight': resource.get('weight', 1.0), + 'existsLocally': exists_locally, + 'localPath': local_path, + 'thumbnailUrl': '', + 'baseModel': '', + 'size': 0, + 'downloadUrl': '' + } + + # Add Civitai info if available + if civitai_info: + # Get thumbnail URL from first image + if 'images' in civitai_info and civitai_info['images']: + lora_entry['thumbnailUrl'] = civitai_info['images'][0].get('url', '') + + # Get base model + lora_entry['baseModel'] = civitai_info.get('baseModel', '') + + # Get file size + if 'files' in civitai_info and civitai_info['files']: + lora_entry['size'] = civitai_info['files'][0].get('sizeKB', 0) * 1024 + + # Get download URL + lora_entry['downloadUrl'] = civitai_info.get('downloadUrl', '') + + loras.append(lora_entry) + + return web.json_response({ + 'base_model': base_model, + 'loras': loras + }) + + except Exception as e: + logger.error(f"Error analyzing recipe image: {e}", exc_info=True) + return web.json_response({ + "error": str(e), + "loras": [] # Return empty loras array to prevent client-side errors + }, status=500) + finally: + # Clean up the temporary file in the finally block + if temp_path and os.path.exists(temp_path): + try: + os.unlink(temp_path) + except Exception as e: + logger.error(f"Error deleting temporary file: {e}") + + async def download_missing_loras(self, request: web.Request) -> web.Response: + """Download missing LoRAs for a recipe""" + try: + data = await request.json() + loras = data.get('loras', []) + lora_root = data.get('lora_root', '') + relative_path = data.get('relative_path', '') + + if not loras: + return web.json_response({"error": "No LoRAs specified"}, status=400) + + if not lora_root: + return web.json_response({"error": "No LoRA root directory specified"}, status=400) + + # Create target directory if it doesn't exist + target_dir = os.path.join(lora_root, relative_path) if relative_path else lora_root + os.makedirs(target_dir, exist_ok=True) + + # Download each LoRA + downloaded = [] + for lora in loras: + download_url = lora.get('downloadUrl') + if not download_url: + continue + + # Generate filename from LoRA name + filename = f"{lora.get('name', 'lora')}.safetensors" + filename = filename.replace(' ', '_').replace('/', '_').replace('\\', '_') + + # Download the file + target_path = os.path.join(target_dir, filename) + + async with aiohttp.ClientSession() as session: + async with session.get(download_url, allow_redirects=True) as response: + if response.status != 200: + continue + + with open(target_path, 'wb') as f: + while True: + chunk = await response.content.read(1024 * 1024) # 1MB chunks + if not chunk: + break + f.write(chunk) + + downloaded.append({ + 'id': lora.get('id'), + 'localPath': target_path + }) + + return web.json_response({ + 'downloaded': downloaded + }) + + except Exception as e: + logger.error(f"Error downloading missing LoRAs: {e}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) + + async def save_recipe(self, request: web.Request) -> web.Response: + """Save a recipe to the recipes folder""" + try: + reader = await request.multipart() + + # Process form data + image = None + name = None + tags = [] + recipe_data = None + + while True: + field = await reader.next() + if field is None: + break + + if field.name == 'image': + # Read image data + image_data = b'' + while True: + chunk = await field.read_chunk() + if not chunk: + break + image_data += chunk + image = image_data + + elif field.name == 'name': + name = await field.text() + + elif field.name == 'tags': + tags_text = await field.text() + try: + tags = json.loads(tags_text) + except: + tags = [] + + elif field.name == 'recipe_data': + recipe_data_text = await field.text() + try: + recipe_data = json.loads(recipe_data_text) + except: + recipe_data = {} + + if not image or not name or not recipe_data: + return web.json_response({"error": "Missing required fields"}, status=400) + + # Create recipes directory if it doesn't exist + recipes_dir = os.path.join(config.loras_roots[0], "recipes") + os.makedirs(recipes_dir, exist_ok=True) + + # Generate filename from recipe name + filename = f"{name}.jpg" + filename = filename.replace(' ', '_').replace('/', '_').replace('\\', '_') + + # Ensure filename is unique + counter = 1 + base_name, ext = os.path.splitext(filename) + while os.path.exists(os.path.join(recipes_dir, filename)): + filename = f"{base_name}_{counter}{ext}" + counter += 1 + + # Save the image + target_path = os.path.join(recipes_dir, filename) + with open(target_path, 'wb') as f: + f.write(image) + + # Add metadata to the image + from PIL import Image + from PIL.ExifTags import TAGS + from piexif import dump, load + import piexif.helper + + # Prepare metadata + metadata = { + 'recipe_name': name, + 'recipe_tags': json.dumps(tags), + 'recipe_data': json.dumps(recipe_data), + 'created_date': str(time.time()) + } + + # Write metadata to image + img = Image.open(target_path) + exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}, "thumbnail": None} + + for key, value in metadata.items(): + exif_dict["0th"][piexif.ImageIFD.XPComment] = piexif.helper.UserComment.dump( + json.dumps({key: value}) + ) + + exif_bytes = dump(exif_dict) + img.save(target_path, exif=exif_bytes) + + # Force refresh the recipe cache + await self.recipe_scanner.get_cached_data(force_refresh=True) + + return web.json_response({ + 'success': True, + 'file_path': target_path + }) + + except Exception as e: + logger.error(f"Error saving recipe: {e}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) \ No newline at end of file diff --git a/refs/civitai_api_model_by_versionId.json b/refs/civitai_api_model_by_versionId.json new file mode 100644 index 00000000..2e096654 --- /dev/null +++ b/refs/civitai_api_model_by_versionId.json @@ -0,0 +1,101 @@ +{ + "id": 1387174, + "modelId": 1231067, + "name": "v1.0", + "createdAt": "2025-02-08T11:15:47.197Z", + "updatedAt": "2025-02-08T11:29:04.526Z", + "status": "Published", + "publishedAt": "2025-02-08T11:29:04.487Z", + "trainedWords": [ + "ppstorybook" + ], + "trainingStatus": null, + "trainingDetails": null, + "baseModel": "Flux.1 D", + "baseModelType": null, + "earlyAccessEndsAt": null, + "earlyAccessConfig": null, + "description": null, + "uploadType": "Created", + "usageControl": "Download", + "air": "urn:air:flux1:lora:civitai:1231067@1387174", + "stats": { + "downloadCount": 1436, + "ratingCount": 0, + "rating": 0, + "thumbsUpCount": 316 + }, + "model": { + "name": "Vivid Impressions Storybook Style", + "type": "LORA", + "nsfw": false, + "poi": false + }, + "files": [ + { + "id": 1289799, + "sizeKB": 18829.1484375, + "name": "pp-storybook_rank2_bf16.safetensors", + "type": "Model", + "pickleScanResult": "Success", + "pickleScanMessage": "No Pickle imports", + "virusScanResult": "Success", + "virusScanMessage": null, + "scannedAt": "2025-02-08T11:21:04.247Z", + "metadata": { + "format": "SafeTensor", + "size": null, + "fp": null + }, + "hashes": { + "AutoV1": "F414C813", + "AutoV2": "9753338AB6", + "SHA256": "9753338AB693CA82BF89ED77A5D1912879E40051463EC6E330FB9866CE798668", + "CRC32": "A65AE7B3", + "BLAKE3": "A5F8AB95AC2486345E4ACCAE541FF19D97ED53EFB0A7CC9226636975A0437591", + "AutoV3": "34A22376739D" + }, + "primary": true, + "downloadUrl": "https://civitai.com/api/download/models/1387174" + } + ], + "images": [ + { + "url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/42b875cf-c62b-41fa-a349-383b7f074351/width=832/56547310.jpeg", + "nsfwLevel": 1, + "width": 832, + "height": 1216, + "hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC", + "type": "image", + "metadata": { + "hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC", + "size": 1361590, + "width": 832, + "height": 1216 + }, + "meta": { + "Size": "832x1216", + "seed": 1116375220995209, + "Model": "flux_dev_fp8", + "steps": 23, + "hashes": { + "model": "" + }, + "prompt": "ppstorybook,A dreamy bunny hopping across a rainbow bridge, with fluffy clouds surrounding it and tiny birds flying alongside, rendered in a magical, soft-focus style with pastel hues and glowing accents.", + "Version": "ComfyUI", + "sampler": "DPM++ 2M", + "cfgScale": 3.5, + "clipSkip": 1, + "resources": [], + "Model hash": "" + }, + "availability": "Public", + "hasMeta": true, + "hasPositivePrompt": true, + "onSite": false, + "remixOfId": null + } + // more images here + ], + "downloadUrl": "https://civitai.com/api/download/models/1387174" +} \ No newline at end of file diff --git a/refs/jpeg_civitai_exif_userComment_example b/refs/jpeg_civitai_exif_userComment_example new file mode 100644 index 00000000..f4301f0e --- /dev/null +++ b/refs/jpeg_civitai_exif_userComment_example @@ -0,0 +1,3 @@ +a dynamic and dramatic digital artwork featuring a stylized anthropomorphic white tiger with striking yellow eyes. The tiger is depicted in a powerful stance, wielding a katana with one hand raised above its head. Its fur is detailed with black stripes, and its mane flows wildly, blending with the stormy background. The scene is set amidst swirling dark clouds and flashes of lightning, enhancing the sense of movement and energy. The composition is vertical, with the tiger positioned centrally, creating a sense of depth and intensity. The color palette is dominated by shades of blue, gray, and white, with bright highlights from the lightning. The overall style is reminiscent of fantasy or manga art, with a focus on dynamic action and dramatic lighting. +Negative prompt: +Steps: 30, Sampler: Undefined, CFG scale: 3.5, Seed: 90300501, Size: 832x1216, Clip skip: 2, Created Date: 2025-03-05T13:51:18.1770234Z, Civitai resources: [{"type":"checkpoint","modelVersionId":691639,"modelName":"FLUX","modelVersionName":"Dev"},{"type":"lora","weight":0.4,"modelVersionId":1202162,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Gothic Lines"},{"type":"lora","weight":0.8,"modelVersionId":1470588,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Retro"},{"type":"lora","weight":0.75,"modelVersionId":746484,"modelName":"Elden Ring - Yoshitaka Amano","modelVersionName":"V1"},{"type":"lora","weight":0.2,"modelVersionId":914935,"modelName":"Ink-style","modelVersionName":"ink-dynamic"},{"type":"lora","weight":0.2,"modelVersionId":1189379,"modelName":"Painterly Fantasy by ChronoKnight - [FLUX \u0026 IL]","modelVersionName":"FLUX"},{"type":"lora","weight":0.2,"modelVersionId":757030,"modelName":"Mezzotint Artstyle for Flux - by Ethanar","modelVersionName":"V1"}], Civitai metadata: {} \ No newline at end of file diff --git a/static/css/components/import-modal.css b/static/css/components/import-modal.css new file mode 100644 index 00000000..fdd1318b --- /dev/null +++ b/static/css/components/import-modal.css @@ -0,0 +1,475 @@ +/* Import Modal Styles */ +.import-step { + margin: var(--space-2) 0; +} + +.input-group { + margin-bottom: var(--space-2); +} + +.input-group label { + display: block; + margin-bottom: 8px; + color: var(--text-color); +} + +.input-group input, +.input-group select { + width: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + background: var(--bg-color); + color: var(--text-color); +} + +.error-message { + color: var(--lora-error); + font-size: 0.9em; + margin-top: 4px; +} + +/* Image Upload Styles */ +.image-upload-container { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.image-preview { + width: 100%; + height: 200px; + border: 2px dashed var(--border-color); + border-radius: var(--border-radius-sm); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background: var(--bg-color); +} + +.image-preview img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.image-preview .placeholder { + color: var(--text-color); + opacity: 0.5; + font-size: 0.9em; +} + +.file-input-wrapper { + position: relative; +} + +.file-input-wrapper input[type="file"] { + opacity: 0; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + cursor: pointer; +} + +.file-input-wrapper .file-input-button { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 16px; + background: var(--lora-surface); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + color: var(--text-color); + cursor: pointer; + transition: all 0.2s ease; +} + +.file-input-wrapper:hover .file-input-button { + background: var(--lora-surface-hover); +} + +/* Recipe Details Styles */ +.recipe-details-container { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.recipe-name-container { + margin-bottom: var(--space-2); +} + +.recipe-name-container label { + display: block; + margin-bottom: 8px; + font-weight: 500; +} + +.recipe-name-container input { + width: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + background: var(--bg-color); + color: var(--text-color); +} + +.tags-section { + margin-bottom: var(--space-2); +} + +.tags-section label { + display: block; + margin-bottom: 8px; + font-weight: 500; +} + +.tag-input-container { + display: flex; + gap: 8px; + margin-bottom: 8px; +} + +.tag-input-container input { + flex: 1; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + background: var(--bg-color); + color: var(--text-color); +} + +.tags-container { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + min-height: 32px; +} + +.recipe-tag { + display: flex; + align-items: center; + gap: 4px; + background: var(--lora-surface); + color: var(--text-color); + padding: 4px 8px; + border-radius: var(--border-radius-xs); + font-size: 0.9em; +} + +.recipe-tag i { + cursor: pointer; + opacity: 0.7; + transition: opacity 0.2s; +} + +.recipe-tag i:hover { + opacity: 1; +} + +.empty-tags { + color: var(--text-color); + opacity: 0.5; + font-size: 0.9em; +} + +/* LoRAs List Styles */ +.loras-list { + max-height: 300px; + overflow-y: auto; + margin: var(--space-2) 0; + display: flex; + flex-direction: column; + gap: 12px; + padding: 1px; +} + +.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); + margin: 1px; + position: relative; +} + +.lora-item.exists-locally { + background: oklch(var(--lora-accent) / 0.05); + border-left: 4px solid var(--lora-accent); +} + +.lora-item.missing-locally { + background: oklch(var(--lora-error) / 0.05); + border-left: 4px solid var(--lora-error); +} + +.lora-thumbnail { + width: 60px; + height: 60px; + flex-shrink: 0; + border-radius: var(--border-radius-xs); + overflow: hidden; + background: var(--bg-color); +} + +.lora-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.lora-content { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; +} + +.lora-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-2); +} + +.lora-content h3 { + margin: 0; + font-size: 1em; + color: var(--text-color); + flex: 1; +} + +.lora-info { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + font-size: 0.9em; +} + +.lora-info .base-model { + background: oklch(var(--lora-accent) / 0.1); + color: var(--lora-accent); + padding: 2px 8px; + border-radius: var(--border-radius-xs); +} + +.weight-badge { + background: var(--lora-surface); + color: var(--text-color); + padding: 2px 8px; + border-radius: var(--border-radius-xs); +} + +.lora-meta { + display: flex; + gap: 12px; + font-size: 0.85em; + color: var(--text-color); + opacity: 0.7; +} + +.lora-meta span { + display: flex; + align-items: center; + gap: 4px; +} + +/* Status Badges */ +.local-badge { + display: inline-flex; + align-items: center; + background: var(--lora-accent); + color: var(--lora-text); + padding: 4px 8px; + border-radius: var(--border-radius-xs); + font-size: 0.8em; + 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: var(--lora-text); + padding: 4px 8px; + border-radius: var(--border-radius-xs); + font-size: 0.8em; + 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; + top: 100%; + right: 0; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + padding: var(--space-1); + margin-top: 4px; + font-size: 0.9em; + color: var(--text-color); + white-space: normal; + word-break: break-all; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + z-index: 1; + min-width: 200px; + max-width: 300px; +} + +.local-badge:hover .local-path { + display: block; +} + +/* Missing LoRAs List */ +.missing-loras-list { + max-height: 150px; + overflow-y: auto; + margin: var(--space-2) 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.missing-lora-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; + background: oklch(var(--lora-error) / 0.05); + border-left: 4px solid var(--lora-error); + border-radius: var(--border-radius-xs); +} + +.lora-name { + font-weight: 500; +} + +.lora-type { + font-size: 0.9em; + opacity: 0.7; +} + +/* Folder Browser Styles */ +.folder-browser { + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + padding: var(--space-1); + max-height: 200px; + overflow-y: auto; +} + +/* Modal Header Styles - Updated to match download-modal */ +.modal-header { + display: flex; + justify-content: space-between; + /* align-items: center; */ + margin-bottom: var(--space-3); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--border-color); + position: relative; +} + +.close-modal { + font-size: 1.5rem; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.2s; + position: absolute; + right: 0; + top: 0; + padding: 0 5px; + line-height: 1; +} + +.close-modal:hover { + opacity: 1; +} + +/* Recipe Details Layout */ +.recipe-details-layout { + display: flex; + gap: var(--space-3); + margin-bottom: var(--space-3); +} + +.recipe-image-container { + flex: 0 0 200px; +} + +.recipe-image { + width: 100%; + height: 200px; + border-radius: var(--border-radius-sm); + overflow: hidden; + border: 1px solid var(--border-color); + background: var(--bg-color); +} + +.recipe-image img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.recipe-form-container { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +/* Simplify file input for step 1 */ +.file-input-wrapper { + margin: var(--space-3) auto; + max-width: 300px; +} + +/* Update LoRA item styles to include version */ +.lora-content { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; +} + +.lora-version { + font-size: 0.85em; + color: var(--text-color); + opacity: 0.7; + margin-top: 2px; +} + +.lora-count-info { + font-size: 0.85em; + font-weight: normal; + color: var(--text-color); + opacity: 0.8; + margin-left: 8px; +} diff --git a/static/js/managers/ImportManager.js b/static/js/managers/ImportManager.js new file mode 100644 index 00000000..34fc6c5c --- /dev/null +++ b/static/js/managers/ImportManager.js @@ -0,0 +1,565 @@ +import { modalManager } from './ModalManager.js'; +import { showToast } from '../utils/uiHelpers.js'; +import { LoadingManager } from './LoadingManager.js'; +import { state } from '../state/index.js'; +import { resetAndReload } from '../api/loraApi.js'; + +export class ImportManager { + constructor() { + this.recipeImage = null; + this.recipeData = null; + this.recipeName = ''; + this.recipeTags = []; + this.missingLoras = []; + + // Add initialization check + this.initialized = false; + this.selectedFolder = ''; + + // Add LoadingManager instance + this.loadingManager = new LoadingManager(); + this.folderClickHandler = null; + this.updateTargetPath = this.updateTargetPath.bind(this); + } + + showImportModal() { + console.log('Showing import modal...'); + if (!this.initialized) { + // Check if modal exists + const modal = document.getElementById('importModal'); + if (!modal) { + console.error('Import modal element not found'); + return; + } + this.initialized = true; + } + + modalManager.showModal('importModal', null, () => { + // Cleanup handler when modal closes + this.cleanupFolderBrowser(); + }); + this.resetSteps(); + } + + resetSteps() { + document.querySelectorAll('.import-step').forEach(step => step.style.display = 'none'); + document.getElementById('uploadStep').style.display = 'block'; + + // Reset file input + const fileInput = document.getElementById('recipeImageUpload'); + if (fileInput) { + fileInput.value = ''; + } + + // Reset error message + const errorElement = document.getElementById('uploadError'); + if (errorElement) { + errorElement.textContent = ''; + } + + // Reset preview + const previewElement = document.getElementById('imagePreview'); + if (previewElement) { + previewElement.innerHTML = '