Compare commits

..

110 Commits

Author SHA1 Message Date
Will Miao
4c4e6d7a7b feat(release-notes): update version to 0.9.1 and enhance bulk operations documentation 2025-09-05 18:15:08 +08:00
Will Miao
03b6e78705 feat(locales): add bulk base model functionality in multiple languages and update toast messages 2025-09-05 18:00:21 +08:00
pixelpaws
24c01141d7 Merge pull request #400 from willmiao/bulk-menu
Bulk menu
2025-09-05 17:44:40 +08:00
Will Miao
6dc2811af4 feat(bulk-modal): refactor bulk base model modal for improved UI and functionality, fixes 352 2025-09-05 17:36:54 +08:00
Will Miao
e6425dce32 feat(bulk-manager): enhance bulk mode handling by skipping actions when a modal is open 2025-09-05 17:07:57 +08:00
Will Miao
95e2ff5f1e Implement centralized event management system with priority handling and state tracking
- Enhanced EventManager class to support priority-based event handling, conditional execution, and automatic cleanup.
- Integrated event management into BulkManager for global keyboard shortcuts and marquee selection events.
- Migrated mouse tracking and node selector events to UIHelpers for better coordination.
- Established global event handlers for context menu interactions and modal state management.
- Added comprehensive documentation for event management implementation and usage.
- Implemented initialization logic for event management, including error handling and cleanup on page unload.
2025-09-05 16:56:26 +08:00
Will Miao
92ac487128 feat(bulk-base-model): implement bulk base model setting functionality with UI and context menu integration 2025-09-05 14:07:03 +08:00
Will Miao
3250fa89cb feat(selection): implement marquee selection for bulk operations 2025-09-05 11:24:48 +08:00
Will Miao
7475de366b feat(context-menu): enhance bulk workflow options with append and replace actions 2025-09-05 11:24:48 +08:00
Will Miao
affb507b37 feat(sync): enhance translation key synchronization to remove obsolete keys 2025-09-05 11:24:48 +08:00
pixelpaws
3320b80150 Merge pull request #399 from willmiao/bulk-menu
Bulk context menu
2025-09-05 09:31:48 +08:00
Will Miao
fb2b69b787 feat(tags): refactor preset tags to constants for better maintainability 2025-09-05 09:27:45 +08:00
Will Miao
29a05f6533 Move test_i18n.py to scripts folder 2025-09-05 08:48:20 +08:00
Will Miao
9fa3fac973 feat(locales): add bulk tag management translations for multiple languages 2025-09-05 08:43:01 +08:00
Will Miao
904b0d104a feat(sync): add translation key synchronization script for locale management 2025-09-05 08:35:20 +08:00
Will Miao
1d31dae110 feat(tags): implement bulk tag addition and replacement functionality 2025-09-05 07:18:24 +08:00
Will Miao
476ecb7423 fix(banner): ensure href attribute defaults to '#' for actions without a URL 2025-09-04 22:09:15 +08:00
Will Miao
4eb67cf6da feat(bulk-tags): add bulk tag management modal and context menu integration 2025-09-04 22:08:55 +08:00
Will Miao
a5a9f7ed83 fix(banner): ensure href attribute defaults to '#' for actions without a URL 2025-09-04 22:07:07 +08:00
Will Miao
c0b029e228 feat(context-menu): refactor context menu initialization and coordination for improved bulk operations 2025-09-04 16:34:05 +08:00
Will Miao
9bebcc9a4b feat(bulk): implement bulk context menu for model operations and remove bulk operations panel 2025-09-04 15:24:54 +08:00
Will Miao
ac7d23011c chore(release): update version to 0.9.0 and add release notes for UI overhaul and new features 2025-09-04 00:04:25 +08:00
pixelpaws
491e09b7b5 Merge pull request #395 from willmiao/ot
Onboarding Tutorial
2025-09-03 23:25:31 +08:00
Will Miao
192bc237bf fix(onboarding): update language selection button text and remove skip option from translations 2025-09-03 23:04:06 +08:00
Will Miao
f041f4a114 feat(onboarding): prevent onboarding from starting if version-mismatch banner is visible 2025-09-03 22:48:29 +08:00
Will Miao
2546580377 fix(localization): update French translations for "recipe" to ensure consistency in terminology 2025-09-03 22:23:35 +08:00
Will Miao
8fbf2ab56d feat(onboarding): add multilingual support for onboarding steps and language selection 2025-09-03 22:17:48 +08:00
Will Miao
ea727aad2e feat(onboarding): enhance target highlighting with mask and pulsing effect 2025-09-03 21:44:23 +08:00
Will Miao
5520aecbba fix(onboarding): adjust language selection logic to skip if already set and update prompt text 2025-09-03 19:22:53 +08:00
Will Miao
6b738a4769 fix(onboarding): update language handling and selection logic in onboarding process 2025-09-03 19:15:55 +08:00
Will Miao
903a8050b3 Add SVG flags for France, Hong Kong, Japan, South Korea, Russia, and the United States
- Added France flag (fr.svg) with three vertical stripes: blue, white, and red.
- Added Hong Kong flag (hk.svg) featuring a red background with a white flower emblem.
- Added Japan flag (jp.svg) with a white field and a red circle in the center.
- Added South Korea flag (kr.svg) showcasing a white background with a central yin-yang symbol and four black trigrams.
- Added Russia flag (ru.svg) with three horizontal stripes: white, blue, and red.
- Added United States flag (us.svg) with red and white stripes and a blue canton featuring stars.
2025-09-03 18:19:34 +08:00
Will Miao
31b032429d fix(sidebar): change default pinned state to true for sidebar restoration 2025-09-03 15:46:33 +08:00
Will Miao
2bcf341f04 feat(onboarding): implement onboarding tutorial with language selection and step guidance 2025-09-03 15:42:36 +08:00
Will Miao
ca6f45b359 fix(download-manager): temporarily disable delay to speed up downloads 2025-09-02 22:36:36 +08:00
Will Miao
2a67cec16b fix(sidebar): update tree selection logic and improve breadcrumb and header state handling 2025-09-02 18:19:01 +08:00
Will Miao
1800afe31b feat(sidebar): implement display mode toggle and update sidebar actions for improved navigation. See #389 2025-09-02 17:42:21 +08:00
Will Miao
91801dff85 feat(localization): add new workflow-related messages for LoRA and recipe actions in multiple languages 2025-09-02 11:50:20 +08:00
Will Miao
be594133f0 feat(localization): update app title from "oRA Manager" to "LoRA Manager" across all locale files 2025-09-02 10:29:29 +08:00
Will Miao
8a538d117e feat(localization): simplify language selection labels and update app title across all locale files 2025-09-02 10:11:55 +08:00
Will Miao
8d9118cbee feat(localization): update control labels and actions for improved clarity in multiple languages 2025-09-01 22:00:19 +08:00
Will Miao
b67464ea13 feat(trigger-word-toggle): update existing tags' active state based on default_active widget value 2025-09-01 20:55:50 +08:00
Will Miao
33334da0bb feat(i18n): add structural consistency tests for locale files and enhance existing tests 2025-09-01 19:29:50 +08:00
pixelpaws
40ce2baa7b Merge pull request #388 from willmiao/i18n
I18n
2025-09-01 08:57:39 +08:00
Will Miao
1134466cc0 feat(i18n): complete locale files for all languages 2025-09-01 08:48:34 +08:00
Will Miao
92341111ad feat(localization): enhance import modal and related components with new labels, descriptions, and error messages for improved user experience 2025-08-31 22:41:35 +08:00
Will Miao
4956d6781f feat(localization): enhance download modal with new labels and error messages for improved user experience 2025-08-31 22:06:59 +08:00
Will Miao
63562240c4 feat(localization): enhance English and Chinese translations for update notifications and support modal 2025-08-31 21:54:54 +08:00
Will Miao
84d801cf14 feat(localization): enhance settings modal with new sections and translations for improved user experience 2025-08-31 21:27:59 +08:00
Will Miao
b56fe4ca68 Implement code changes to enhance functionality and improve performance 2025-08-31 20:55:08 +08:00
Will Miao
6c83c65e02 feat(localization): add custom filter message and update toast keys for recipe actions 2025-08-31 20:32:37 +08:00
Will Miao
a83f020fcc feat(localization): add file size labels and enhance search placeholders in UI components 2025-08-31 20:26:13 +08:00
Will Miao
7f9a3bf272 feat(i18n): enhance translation key extraction to optionally include container nodes 2025-08-31 19:01:23 +08:00
Will Miao
f80e266d02 feat(localization): update toast messages for consistency and improved error handling across various components 2025-08-31 18:38:42 +08:00
Will Miao
7bef562541 feat(localization): update toast messages for improved user feedback and localization support across various components 2025-08-31 16:52:58 +08:00
Will Miao
b2428f607c feat(localization): add trigger words functionality with localization support for UI elements and messages 2025-08-31 15:13:12 +08:00
Will Miao
8303196b57 feat(localization): enhance toast messages for context menu actions, model tags, and download management with improved error handling and user feedback 2025-08-31 14:27:33 +08:00
Will Miao
987b8c8742 feat(localization): enhance toast messages for recipes and example images with improved error handling and success feedback 2025-08-31 13:51:37 +08:00
Will Miao
e60a579b85 feat(localization): enhance toast messages for API actions and model management with i18n support
refactor(localization): update toast messages in various components and managers for better user feedback
2025-08-31 12:25:08 +08:00
Will Miao
be8edafed0 feat(localization): enhance toast messages for better user feedback and localization support 2025-08-31 11:51:28 +08:00
Will Miao
a258a18fa4 refactor(preload): remove unnecessary preload blocks from multiple templates 2025-08-31 11:28:49 +08:00
Will Miao
59010ca431 Refactor localization handling and improve i18n support across the application
- Replaced `safeTranslate` with `translate` in various components for consistent translation handling.
- Updated Chinese (Simplified and Traditional) localization files to include new keys and improved translations for model card actions, metadata, and usage tips.
- Enhanced the ModelCard, ModelDescription, ModelMetadata, ModelModal, and ModelTags components to utilize the new translation functions.
- Improved user feedback messages for actions like copying to clipboard, saving notes, and updating tags with localized strings.
- Ensured all UI elements reflect the correct translations based on the user's language preference.
2025-08-31 11:19:06 +08:00
Will Miao
75f3764e6c refactor(i18n): optimize safeTranslate usage by removing unnecessary await calls 2025-08-31 10:32:15 +08:00
Will Miao
867ffd1163 feat(localization): add model description translations and enhance UI text across multiple languages 2025-08-31 10:12:54 +08:00
Will Miao
6acccbbb94 fix(localization): update language labels to use English and native scripts for consistency 2025-08-31 09:16:26 +08:00
Will Miao
b2c4efab45 refactor(i18n): streamline i18n initialization and update translation methods 2025-08-31 09:03:06 +08:00
Will Miao
408a435b71 Add copilot instructions to enforce English for comments 2025-08-31 09:02:51 +08:00
Will Miao
36d3cd93d5 Enhance localization and UI for model management features
- Added new localization keys for usage statistics, collection analysis, storage efficiency, and insights in English and Chinese.
- Updated modal templates to utilize localization for delete, exclude, and bulk delete confirmations.
- Improved download modal with localized labels and placeholders.
- Enhanced example access modal with localized titles and descriptions.
- Updated help modal to include localized content for update vlogs and documentation sections.
- Refactored move modal to use localization for labels and buttons.
- Implemented localization in relink Civitai modal for warnings and help text.
- Updated update modal to reflect localized text for actions and progress messages.
- Enhanced statistics template with localized titles for charts and lists.
2025-08-30 23:20:13 +08:00
Will Miao
b36fea002e Add localization support for new features and update existing translations
- Added "unknown" status to model states in English and Chinese locales.
- Introduced new actions for checking updates and support in both locales.
- Added settings for Civitai API key with help text in both locales.
- Updated context menus and control components to use localized strings.
- Enhanced help and support modals with localization.
- Updated update modal to reflect current and new version information in localized format.
- Refactored various templates to utilize the translation function for better internationalization.
2025-08-30 22:32:44 +08:00
Will Miao
52acbd954a Add Chinese (Simplified and Traditional) localization files and implement i18n tests
- Created zh-CN.json and zh-TW.json for Simplified and Traditional Chinese translations respectively.
- Added comprehensive test suite in test_i18n.py to validate JSON structure, server-side i18n functionality, and translation completeness across multiple languages.
2025-08-30 21:41:48 +08:00
Will Miao
f6709a55c3 refactor(i18n): Remove server_i18n references from routes and update translations in zh-CN and zh-TW locales 2025-08-30 19:02:37 +08:00
Will Miao
7b374d747b cleanup 2025-08-30 18:44:33 +08:00
Will Miao
fd480a9360 refactor(i18n): Remove language setting endpoints and related logic from MiscRoutes 2025-08-30 17:48:32 +08:00
Will Miao
ec8b228867 fix(statistics): Add margin-top to metrics grid for improved spacing 2025-08-30 17:30:49 +08:00
Will Miao
401200050b feat(i18n): Enhance internationalization support by updating storage retrieval and translation handling 2025-08-30 17:29:04 +08:00
Will Miao
29160bd6e5 feat(i18n): Implement server-side internationalization support
- Added ServerI18nManager to handle translations and locale settings on the server.
- Integrated server-side translations into templates, reducing language flashing on initial load.
- Created API endpoints for setting and getting user language preferences.
- Enhanced client-side i18n handling to work seamlessly with server-rendered content.
- Updated various templates to utilize the new translation system.
- Added mixed i18n handler to coordinate server and client translations, improving user experience.
- Expanded translation files to include initialization messages for various components.
2025-08-30 16:56:56 +08:00
Will Miao
3c9e402bc0 Add Korean, Russian, and Traditional Chinese translations for LoRA Manager 2025-08-30 11:32:39 +08:00
Will Miao
ff4d0f0208 feat: Update Simplified Chinese translations for LoRA Manager to improve clarity and consistency 2025-08-29 21:32:48 +08:00
Will Miao
f82908221c Implement internationalization (i18n) system for LoRA Manager
- Added i18n support with automatic language detection based on browser settings.
- Implemented translations for English (en) and Simplified Chinese (zh-CN).
- Created utility functions for text replacement in HTML templates and JavaScript.
- Developed a comprehensive translation key structure for various application components.
- Added formatting functions for numbers, dates, and file sizes according to locale.
- Included RTL language support and dynamic updates for DOM elements.
- Created tests to verify the functionality of the i18n system.
2025-08-29 21:32:48 +08:00
Will Miao
4246908f2e feat: Add updateContainerMargin method and integrate it into sidebar state management for improved layout handling 2025-08-29 21:28:19 +08:00
Will Miao
f64597afd2 feat: Update restoreSelectedFolder to ensure activeFolder is a string before assignment and reset selectedPath if not 2025-08-29 17:46:43 +08:00
Will Miao
975ff2672d feat: Add new Flux model 'FLUX_1_KREA' and update Video Models list for enhanced model support 2025-08-28 16:24:01 +08:00
Will Miao
e90ba31784 feat: Update filter_civitai_data to include 'id' and 'modelId' fields for improved data retrieval 2025-08-28 15:21:04 +08:00
Will Miao
a4074c93bc feat: Improve folder filtering logic to ensure exact matches and handle root folder case 2025-08-28 05:33:53 +08:00
Will Miao
7a8b7598c7 feat: Enhance deepMerge function to only update existing keys in target for improved merging logic 2025-08-27 20:42:57 +08:00
Will Miao
cd0d832f14 feat: Refactor showModelModal to fetch complete metadata and update related functions for improved data handling 2025-08-27 19:42:34 +08:00
Will Miao
5b0becaaf2 feat: Implement model description retrieval and update related API endpoints 2025-08-27 18:22:56 +08:00
Will Miao
9817bac2fe feat: Add metadata endpoint and implement model metadata retrieval functionality 2025-08-27 17:44:29 +08:00
Will Miao
f6bd48cfcd feat: Update box-shadow for header and adjust controls styling for improved layout 2025-08-27 15:43:44 +08:00
Will Miao
01843b8f2b feat: Update media query breakpoints from 2000px to 2150px for improved responsiveness across components 2025-08-27 09:54:08 +08:00
Will Miao
94ed81de5e feat: Update tooltip positioning comments for clarity and consistency 2025-08-27 09:11:19 +08:00
Will Miao
0700b8f399 feat: Adjust sidebar position to align with viewport edges for improved layout consistency 2025-08-27 09:11:05 +08:00
Will Miao
d62cff9841 feat: Refactor SidebarManager integration and cleanup methods for improved state management 2025-08-26 21:38:33 +08:00
Will Miao
083f4805b2 feat: Enhance get_preview_static_url to find the longest matching route for static URLs 2025-08-26 20:41:01 +08:00
Will Miao
8e5bfd379e feat: Add closeDropdown method to manage dropdown state in SidebarManager 2025-08-26 19:26:05 +08:00
pixelpaws
2366f143d8 Merge pull request #377 from willmiao/sidebar, See #257 #52
Sidebar
2025-08-26 19:10:30 +08:00
Will Miao
e997f5bc1b feat: Update activeFolder state initialization to load from localStorage for each model type 2025-08-26 19:04:23 +08:00
Will Miao
842beec7cc feat: Update recursive search option to default to true and remove related UI elements 2025-08-26 18:14:43 +08:00
Will Miao
d2268fc9e0 feat: Implement initial hidden state for sidebar and enhance visibility handling 2025-08-26 18:02:52 +08:00
Will Miao
a98e26139f feat: Implement auto-hide functionality for sidebar and update controls layout 2025-08-26 17:57:59 +08:00
Will Miao
522a3ea88b feat: Update sidebar breadcrumb styles and enhance dropdown functionality 2025-08-26 17:13:04 +08:00
Will Miao
d7949fbc30 feat: Enhance sidebar navigation with dropdowns and refactor breadcrumb structure 2025-08-26 16:44:01 +08:00
Will Miao
6df083a1d5 feat: Refactor sidebar components for improved structure and styling 2025-08-26 15:26:45 +08:00
Will Miao
4dc80e7f6e feat: Implement sidebar navigation with folder tree and controls 2025-08-26 10:33:46 +08:00
Will Miao
c2a8508513 feat: Add get_preview_extension function to retrieve complete preview file extensions 2025-08-26 10:19:17 +08:00
Will Miao
159193ef43 feat: Implement unique filename generation with conflict resolution using metadata hash 2025-08-25 15:33:46 +08:00
Will Miao
1f37ffb105 feat: Refactor unique filename generation to use a hash provider for improved flexibility 2025-08-25 14:52:44 +08:00
Will Miao
919fed05c5 feat: Enhance model moving functionality with improved error handling and unique filename generation 2025-08-25 13:08:35 +08:00
Will Miao
1814f83bee feat: Implement post-initialization tasks and backup file cleanup in LoraManager 2025-08-25 09:03:40 +08:00
Will Miao
1823840456 feat: Disable image optimization in find_preview_file function for future configuration 2025-08-25 09:03:28 +08:00
Will Miao
623c28bfc3 feat: Remove backup creation from metadata saving functions for streamlined operations 2025-08-24 22:30:53 +08:00
152 changed files with 19904 additions and 2603 deletions

1
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1 @@
Always use English for comments.

View File

@@ -34,6 +34,17 @@ Enhance your Civitai browsing experience with our companion browser extension! S
## Release Notes
### v0.9.1
* **Enhanced Bulk Operations** - Improved bulk operations with Marquee Selection and a bulk operation context menu, providing a more intuitive, desktop-application-like user experience.
* **New Bulk Actions** - Added bulk operations for adding tags and setting base models to multiple models simultaneously.
### v0.9.0
* **UI Overhaul for Enhanced Navigation** - Replaced the top flat folder tags with a new folder sidebar and breadcrumb navigation system for a more intuitive folder browsing and selection experience.
* **Dual-Mode Folder Sidebar** - The new folder sidebar offers two display modes: 'List Mode,' which mirrors the classic folder view, and 'Tree Mode,' which presents a hierarchical folder structure for effortless navigation through nested directories.
* **Internationalization Support** - Introduced multi-language support, now available in English, Simplified Chinese, Traditional Chinese, Spanish, Japanese, Korean, French, Russian, and German. Feedback from native speakers is welcome to improve the translations.
* **Automatic Filename Conflict Resolution** - Implemented automatic file renaming (`original name + short hash`) to prevent conflicts when downloading or moving models.
* **Performance Optimizations & Bug Fixes** - Various performance improvements and bug fixes for a more stable and responsive experience.
### v0.8.30
* **Automatic Model Path Correction** - Added auto-correction for model paths in built-in nodes such as Load Checkpoint, Load Diffusion Model, Load LoRA, and other custom nodes with similar functionality. Workflows containing outdated or incorrect model paths will now be automatically updated to reflect the current location of your models.
* **Node UI Enhancements** - Improved node interface for a smoother and more intuitive user experience.
@@ -62,61 +73,6 @@ Enhance your Civitai browsing experience with our companion browser extension! S
* **Enhanced Node Usability** - Improved user experience for Lora Loader, Lora Stacker, and WanVideo Lora Select nodes by fixing the maximum height of the text input area. Users can now freely and conveniently adjust the LoRA region within these nodes.
* **Compatibility Fixes** - Resolved compatibility issues with ComfyUI and certain custom nodes, including ComfyUI-Custom-Scripts, ensuring smoother integration and operation.
### v0.8.25
* **LoRA List Reordering**
- Drag & Drop: Easily rearrange LoRA entries using the drag handle.
- Keyboard Shortcuts:
- Arrow keys: Navigate between LoRAs
- Ctrl/Cmd + Arrow: Move selected LoRA up/down
- Ctrl/Cmd + Home/End: Move selected LoRA to top/bottom
- Delete/Backspace: Remove selected LoRA
- Context Menu: Right-click for quick actions like Move Up, Move Down, Move to Top, Move to Bottom.
* **Bulk Operations for Checkpoints & Embeddings**
- Bulk Mode: Select multiple checkpoints or embeddings for batch actions.
- Bulk Refresh: Update Civitai metadata for selected models.
- Bulk Delete: Remove multiple models at once.
- Bulk Move (Embeddings): Move selected embeddings to a different folder.
* **New Setting: Auto Download Example Images**
- Automatically fetch example images for models missing previews (requires download location to be set). Enabled by default.
* **General Improvements**
- Various user experience enhancements and stability fixes.
### v0.8.22
* **Embeddings Management** - Added Embeddings page for comprehensive embedding model management.
* **Advanced Sorting Options** - Introduced flexible sorting controls, allowing sorting by name, added date, or file size in both ascending and descending order.
* **Custom Download Path Templates & Base Model Mapping** - Implemented UI settings for configuring download path templates and base model path mappings, allowing customized model organization and storage location when downloading models via LM Civitai Extension.
* **LM Civitai Extension Enhancements** - Improved concurrent download performance and stability, with new support for canceling active downloads directly from the extension interface.
* **Update Feature** - Added update functionality, allowing users to update LoRA Manager to the latest release version directly from the LoRA Manager UI.
* **Bulk Operations: Refresh All** - Added bulk refresh functionality, allowing users to update Civitai metadata across multiple LoRAs.
### v0.8.20
* **LM Civitai Extension** - Released [browser extension through Chrome Web Store](https://chromewebstore.google.com/detail/lm-civitai-extension/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) that works seamlessly with LoRA Manager to enhance Civitai browsing experience, showing which models are already in your local library, enabling one-click downloads, and providing queue and parallel download support
* **Enhanced Lora Loader** - Added support for nunchaku, improving convenience when working with ComfyUI-nunchaku workflows, plus new template workflows for quick onboarding
* **WanVideo Integration** - Introduced WanVideo Lora Select (LoraManager) node compatible with ComfyUI-WanVideoWrapper for streamlined lora usage in video workflows, including a template workflow to help you get started quickly
### v0.8.19
* **Analytics Dashboard** - Added new Statistics page providing comprehensive visual analysis of model collection and usage patterns for better library insights
* **Target Node Selection** - Enhanced workflow integration with intelligent target choosing when sending LoRAs/recipes to workflows with multiple loader/stacker nodes; a visual selector now appears showing node color, type, ID, and title for precise targeting
* **Enhanced NSFW Controls** - Added support for setting NSFW levels on recipes with automatic content blurring based on user preferences
* **Customizable Card Display** - New display settings allowing users to choose whether card information and action buttons are always visible or only revealed on hover
* **Expanded Compatibility** - Added support for efficiency-nodes-comfyui in Save Recipe and Save Image nodes, plus fixed compatibility with ComfyUI_Custom_Nodes_AlekPet
### v0.8.18
* **Custom Example Images** - Added ability to import your own example images for LoRAs and checkpoints with automatic metadata extraction from embedded information
* **Enhanced Example Management** - New action buttons to set specific examples as previews or delete custom examples
* **Improved Duplicate Detection** - Enhanced "Find Duplicates" with hash verification feature to eliminate false positives when identifying duplicate models
* **Tag Management** - Added tag editing functionality allowing users to customize and manage model tags
* **Advanced Selection Controls** - Implemented Ctrl+A shortcut for quickly selecting all filtered LoRAs, automatically entering bulk mode when needed
* **Note**: Cache file functionality temporarily disabled pending rework
### v0.8.17
* **Duplicate Model Detection** - Added "Find Duplicates" functionality for LoRAs and checkpoints using model file hash detection, enabling convenient viewing and batch deletion of duplicate models
* **Enhanced URL Recipe Imports** - Optimized import recipe via URL functionality using CivitAI API calls instead of web scraping, now supporting all rated images (including NSFW) for recipe imports
* **Improved TriggerWord Control** - Enhanced TriggerWord Toggle node with new default_active switch to set the initial state (active/inactive) when trigger words are added
* **Centralized Example Management** - Added "Migrate Existing Example Images" feature to consolidate downloaded example images from model folders into central storage with customizable naming patterns
* **Intelligent Word Suggestions** - Implemented smart trigger word suggestions by reading class tokens and tag frequency from safetensors files, displaying recommendations when editing trigger words
* **Model Version Management** - Added "Re-link to CivitAI" context menu option for connecting models to different CivitAI versions when needed
[View Update History](./update_logs.md)
---

View File

@@ -0,0 +1,182 @@
# Event Management Implementation Summary
## What Has Been Implemented
### 1. Enhanced EventManager Class
- **Location**: `static/js/utils/EventManager.js`
- **Features**:
- Priority-based event handling
- Conditional execution based on application state
- Element filtering (target/exclude selectors)
- Mouse button filtering
- Automatic cleanup with cleanup functions
- State tracking for app modes
- Error handling for event handlers
### 2. BulkManager Integration
- **Location**: `static/js/managers/BulkManager.js`
- **Migrated Events**:
- Global keyboard shortcuts (Ctrl+A, Escape, B key)
- Marquee selection events (mousedown, mousemove, mouseup, contextmenu)
- State synchronization with EventManager
- **Benefits**:
- Centralized priority handling
- Conditional execution based on modal state
- Better coordination with other components
### 3. UIHelpers Integration
- **Location**: `static/js/utils/uiHelpers.js`
- **Migrated Events**:
- Mouse position tracking for node selector positioning
- Node selector click events (outside clicks and selection)
- State management for node selector
- **Benefits**:
- Reduced direct DOM listeners
- Coordinated state tracking
- Better cleanup
### 4. ModelCard Integration
- **Location**: `static/js/components/shared/ModelCard.js`
- **Migrated Events**:
- Model card click delegation
- Action button handling (star, globe, copy, etc.)
- Better return value handling for event propagation
- **Benefits**:
- Single event listener for all model cards
- Priority-based execution
- Better event flow control
### 5. Documentation and Initialization
- **EventManagerDocs.md**: Comprehensive documentation
- **eventManagementInit.js**: Initialization and global handlers
- **Features**:
- Global escape key handling
- Modal state synchronization
- Error handling
- Analytics integration points
- Cleanup on page unload
## Application States Tracked
1. **bulkMode**: When bulk selection mode is active
2. **marqueeActive**: When marquee selection is in progress
3. **modalOpen**: When any modal dialog is open
4. **nodeSelectorActive**: When node selector popup is visible
## Priority Levels Used
- **250+**: Critical system events (escape keys)
- **200+**: High priority system events (modal close)
- **100-199**: Application-level shortcuts (bulk operations)
- **80-99**: UI interactions (marquee selection)
- **60-79**: Component interactions (model cards)
- **10-49**: Tracking and monitoring
- **1-9**: Analytics and low-priority tasks
## Event Flow Examples
### Bulk Mode Toggle (B key)
1. **Priority 100**: BulkManager keyboard handler catches 'b' key
2. Toggles bulk mode state
3. Updates EventManager state
4. Updates UI accordingly
5. Stops propagation (returns true)
### Marquee Selection
1. **Priority 80**: BulkManager mousedown handler (only in .models-container, excluding cards/buttons)
2. Starts marquee selection
3. **Priority 90**: BulkManager mousemove handler (only when marquee active)
4. Updates selection rectangle
5. **Priority 90**: BulkManager mouseup handler ends selection
### Model Card Click
1. **Priority 60**: ModelCard delegation handler checks for specific elements
2. If action button: handles action and stops propagation
3. If general card click: continues to other handlers
4. Bulk selection may also handle the event if in bulk mode
## Remaining Event Listeners (Not Yet Migrated)
### High Priority for Migration
1. **SearchManager keyboard events** - Global search shortcuts
2. **ModalManager escape handling** - Already integrated with initialization
3. **Scroll-based events** - Back to top, virtual scrolling
4. **Resize events** - Panel positioning, responsive layouts
### Medium Priority
1. **Form input events** - Tag inputs, settings forms
2. **Component-specific events** - Recipe modal, showcase view
3. **Sidebar events** - Resize handling, toggle events
### Low Priority (Can Remain As-Is)
1. **VirtualScroller events** - Performance-critical, specialized
2. **Component lifecycle events** - Modal open/close callbacks
3. **One-time setup events** - Theme initialization, etc.
## Benefits Achieved
### Performance Improvements
- **Reduced DOM listeners**: From ~15+ individual listeners to ~5 coordinated handlers
- **Conditional execution**: Handlers only run when conditions are met
- **Priority ordering**: Important events handled first
- **Better memory management**: Automatic cleanup prevents leaks
### Coordination Improvements
- **State synchronization**: All components aware of app state
- **Event flow control**: Proper propagation stopping
- **Conflict resolution**: Priority system prevents conflicts
- **Debugging**: Centralized event handling for easier debugging
### Code Quality Improvements
- **Consistent patterns**: All event handling follows same patterns
- **Better separation of concerns**: Event logic separated from business logic
- **Error handling**: Centralized error catching and reporting
- **Documentation**: Clear patterns for future development
## Next Steps (Recommendations)
### 1. Migrate Search Events
```javascript
// In SearchManager.js
eventManager.addHandler('keydown', 'search-shortcuts', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
this.focusSearchInput();
return true;
}
}, { priority: 120 });
```
### 2. Integrate Resize Events
```javascript
// Create ResizeManager
eventManager.addHandler('resize', 'layout-resize', debounce((e) => {
this.updateLayoutDimensions();
}, 250), { priority: 50 });
```
### 3. Add Debug Mode
```javascript
// In EventManager.js
if (window.DEBUG_EVENTS) {
console.log(`Event ${eventType} handled by ${source} (priority: ${priority})`);
}
```
### 4. Create Event Analytics
```javascript
// Track event patterns for optimization
eventManager.addHandler('*', 'analytics', (e) => {
this.trackEventUsage(e.type, performance.now());
}, { priority: 1 });
```
## Testing Recommendations
1. **Verify bulk mode interactions** work correctly
2. **Test marquee selection** in various scenarios
3. **Check modal state synchronization**
4. **Verify node selector** positioning and cleanup
5. **Test keyboard shortcuts** don't conflict
6. **Verify proper cleanup** when components are destroyed
The centralized event management system provides a solid foundation for coordinated, efficient event handling across the application while maintaining good performance and code organization.

301
docs/EventManagerDocs.md Normal file
View File

@@ -0,0 +1,301 @@
# Centralized Event Management System
This document describes the centralized event management system that coordinates event handling across the ComfyUI LoRA Manager application.
## Overview
The `EventManager` class provides a centralized way to handle DOM events with priority-based execution, conditional execution based on application state, and proper cleanup mechanisms.
## Features
- **Priority-based execution**: Handlers with higher priority run first
- **Conditional execution**: Handlers can be executed based on application state
- **Element filtering**: Handlers can target specific elements or exclude others
- **Automatic cleanup**: Cleanup functions are called when handlers are removed
- **State tracking**: Tracks application states like bulk mode, modal open, etc.
## Basic Usage
### Importing
```javascript
import { eventManager } from './EventManager.js';
```
### Adding Event Handlers
```javascript
eventManager.addHandler('click', 'myComponent', (event) => {
console.log('Button clicked!');
return true; // Stop propagation to other handlers
}, {
priority: 100,
targetSelector: '.my-button',
skipWhenModalOpen: true
});
```
### Removing Event Handlers
```javascript
// Remove specific handler
eventManager.removeHandler('click', 'myComponent');
// Remove all handlers for a component
eventManager.removeAllHandlersForSource('myComponent');
```
### Updating Application State
```javascript
// Set state
eventManager.setState('bulkMode', true);
eventManager.setState('modalOpen', true);
// Get state
const isBulkMode = eventManager.getState('bulkMode');
```
## Available States
- `bulkMode`: Whether bulk selection mode is active
- `marqueeActive`: Whether marquee selection is in progress
- `modalOpen`: Whether any modal is currently open
- `nodeSelectorActive`: Whether the node selector popup is active
## Handler Options
### Priority
Higher numbers = higher priority. Handlers run in descending priority order.
```javascript
{
priority: 100 // High priority
}
```
### Conditional Execution
```javascript
{
onlyInBulkMode: true, // Only run when bulk mode is active
onlyWhenMarqueeActive: true, // Only run when marquee selection is active
skipWhenModalOpen: true, // Skip when any modal is open
skipWhenNodeSelectorActive: true, // Skip when node selector is active
onlyWhenNodeSelectorActive: true // Only run when node selector is active
}
```
### Element Filtering
```javascript
{
targetSelector: '.model-card', // Only handle events on matching elements
excludeSelector: 'button, input', // Exclude events from these elements
button: 0 // Only handle specific mouse button (0=left, 1=middle, 2=right)
}
```
### Cleanup Functions
```javascript
{
cleanup: () => {
// Custom cleanup logic
console.log('Handler cleaned up');
}
}
```
## Integration Examples
### BulkManager Integration
```javascript
class BulkManager {
registerEventHandlers() {
// High priority keyboard shortcuts
eventManager.addHandler('keydown', 'bulkManager-keyboard', (e) => {
return this.handleGlobalKeyboard(e);
}, {
priority: 100,
skipWhenModalOpen: true
});
// Marquee selection
eventManager.addHandler('mousedown', 'bulkManager-marquee-start', (e) => {
return this.handleMarqueeStart(e);
}, {
priority: 80,
skipWhenModalOpen: true,
targetSelector: '.models-container',
excludeSelector: '.model-card, button, input',
button: 0
});
}
cleanup() {
eventManager.removeAllHandlersForSource('bulkManager-keyboard');
eventManager.removeAllHandlersForSource('bulkManager-marquee-start');
}
}
```
### Modal Integration
```javascript
class ModalManager {
showModal(modalId) {
// Update state when modal opens
eventManager.setState('modalOpen', true);
this.displayModal(modalId);
}
closeModal(modalId) {
// Update state when modal closes
eventManager.setState('modalOpen', false);
this.hideModal(modalId);
}
}
```
### Component Event Delegation
```javascript
export function setupComponentEvents() {
eventManager.addHandler('click', 'myComponent-actions', (event) => {
const button = event.target.closest('.action-button');
if (!button) return false;
this.handleAction(button.dataset.action);
return true; // Stop propagation
}, {
priority: 60,
targetSelector: '.component-container'
});
}
```
## Best Practices
### 1. Use Descriptive Source Names
Use the format `componentName-purposeDescription`:
```javascript
// Good
'bulkManager-marqueeSelection'
'nodeSelector-clickOutside'
'modelCard-delegation'
// Avoid
'bulk'
'click'
'handler1'
```
### 2. Set Appropriate Priorities
- 200+: Critical system events (escape keys, critical modals)
- 100-199: High priority application events (keyboard shortcuts)
- 50-99: Normal UI interactions (buttons, cards)
- 1-49: Low priority events (tracking, analytics)
### 3. Use Conditional Execution
Instead of checking state inside handlers, use options:
```javascript
// Good
eventManager.addHandler('click', 'bulk-action', handler, {
onlyInBulkMode: true
});
// Avoid
eventManager.addHandler('click', 'bulk-action', (e) => {
if (!state.bulkMode) return;
// handler logic
});
```
### 4. Clean Up Properly
Always clean up handlers when components are destroyed:
```javascript
class MyComponent {
constructor() {
this.registerEvents();
}
destroy() {
eventManager.removeAllHandlersForSource('myComponent');
}
}
```
### 5. Return Values Matter
- Return `true` to stop event propagation to other handlers
- Return `false` or `undefined` to continue with other handlers
## Migration Guide
### From Direct Event Listeners
**Before:**
```javascript
document.addEventListener('click', (e) => {
if (e.target.closest('.my-button')) {
this.handleClick(e);
}
});
```
**After:**
```javascript
eventManager.addHandler('click', 'myComponent-button', (e) => {
this.handleClick(e);
}, {
targetSelector: '.my-button'
});
```
### From Event Delegation
**Before:**
```javascript
container.addEventListener('click', (e) => {
const card = e.target.closest('.model-card');
if (!card) return;
if (e.target.closest('.action-btn')) {
this.handleAction(e);
}
});
```
**After:**
```javascript
eventManager.addHandler('click', 'container-actions', (e) => {
const card = e.target.closest('.model-card');
if (!card) return false;
if (e.target.closest('.action-btn')) {
this.handleAction(e);
return true;
}
}, {
targetSelector: '.container'
});
```
## Performance Benefits
1. **Reduced DOM listeners**: Single listener per event type instead of multiple
2. **Conditional execution**: Handlers only run when conditions are met
3. **Priority ordering**: Important handlers run first, avoiding unnecessary work
4. **Automatic cleanup**: Prevents memory leaks from orphaned listeners
5. **Centralized debugging**: All event handling flows through one system
## Debugging
Enable debug logging to trace event handling:
```javascript
// Add to EventManager.js for debugging
console.log(`Handling ${eventType} event with ${handlers.length} handlers`);
```
The event manager provides a foundation for coordinated, efficient event handling across the entire application.

170
i18n_migration_summary.md Normal file
View File

@@ -0,0 +1,170 @@
# i18n System Migration Complete
## 概要 (Summary)
成功完成了从JavaScript ES6模块到JSON格式的国际化系统迁移包含完整的多语言翻译和代码更新。
Successfully completed the migration from JavaScript ES6 modules to JSON format for the internationalization system, including complete multilingual translations and code updates.
## 完成的工作 (Completed Work)
### 1. 文件结构重组 (File Structure Reorganization)
- **新建目录**: `/locales/` - 集中存放所有JSON翻译文件
- **移除目录**: `/static/js/i18n/locales/` - 删除了旧的JavaScript文件
### 2. 格式转换 (Format Conversion)
- **转换前**: ES6模块格式 (`export const en = { ... }`)
- **转换后**: 标准JSON格式 (`{ ... }`)
- **支持语言**: 9种语言完全转换
- English (en)
- 简体中文 (zh-CN)
- 繁體中文 (zh-TW)
- 日本語 (ja)
- Русский (ru)
- Deutsch (de)
- Français (fr)
- Español (es)
- 한국어 (ko)
### 3. 翻译完善 (Translation Completion)
- **翻译条目**: 每种语言386个翻译键值对
- **覆盖范围**: 完整覆盖所有UI元素
- **质量保证**: 所有翻译键在各语言间保持一致
### 4. JavaScript代码更新 (JavaScript Code Updates)
#### 主要修改文件: `static/js/i18n/index.js`
```javascript
// 旧版本: 静态导入
import { en } from './locales/en.js';
// 新版本: 动态JSON加载
async loadLocale(locale) {
const response = await fetch(`/locales/${locale}.json`);
return await response.json();
}
```
#### 核心功能更新:
- **构造函数**: 从静态导入改为配置驱动
- **语言加载**: 异步JSON获取机制
- **初始化**: 支持Promise-based的异步初始化
- **错误处理**: 增强的回退机制到英语
- **向后兼容**: 保持现有API接口不变
### 5. Python服务端更新 (Python Server-side Updates)
#### 修改文件: `py/services/server_i18n.py`
```python
# 旧版本: 解析JavaScript文件
def _load_locale_file(self, path, filename, locale_code):
# 复杂的JS到JSON转换逻辑
# 新版本: 直接加载JSON
def _load_locale_file(self, path, filename, locale_code):
with open(file_path, 'r', encoding='utf-8') as f:
translations = json.load(f)
```
#### 路径更新:
- **旧路径**: `static/js/i18n/locales/*.js`
- **新路径**: `locales/*.json`
### 6. 服务器路由配置 (Server Route Configuration)
#### 修改文件: `standalone.py`
```python
# 新增静态路由服务JSON文件
app.router.add_static('/locales', locales_path)
```
## 技术架构 (Technical Architecture)
### 前端 (Frontend)
```
Browser → JavaScript i18n Manager → fetch('/locales/{lang}.json') → JSON Response
```
### 后端 (Backend)
```
Python Server → ServerI18nManager → Direct JSON loading → Template Rendering
```
### 文件组织 (File Organization)
```
ComfyUI-Lora-Manager/
├── locales/ # 新的JSON翻译文件目录
│ ├── en.json # 英语翻译 (基准)
│ ├── zh-CN.json # 简体中文翻译
│ ├── zh-TW.json # 繁体中文翻译
│ ├── ja.json # 日语翻译
│ ├── ru.json # 俄语翻译
│ ├── de.json # 德语翻译
│ ├── fr.json # 法语翻译
│ ├── es.json # 西班牙语翻译
│ └── ko.json # 韩语翻译
├── static/js/i18n/
│ └── index.js # 更新的JavaScript i18n管理器
└── py/services/
└── server_i18n.py # 更新的Python服务端i18n
```
## 测试验证 (Testing & Validation)
### 测试脚本: `test_i18n.py`
```bash
🚀 Testing updated i18n system...
✅ All JSON locale files are valid (9 languages)
✅ Server-side i18n system working correctly
✅ All languages have complete translations (386 keys each)
🎉 All tests passed!
```
### 验证内容:
1. **JSON文件完整性**: 所有文件格式正确,语法有效
2. **翻译完整性**: 各语言翻译键值一致,无缺失
3. **服务端功能**: Python i18n服务正常加载和翻译
4. **参数插值**: 动态参数替换功能正常
## 优势与改进 (Benefits & Improvements)
### 1. 维护性提升
- **简化格式**: JSON比JavaScript对象更易于编辑和维护
- **工具支持**: 更好的编辑器语法高亮和验证支持
- **版本控制**: 更清晰的diff显示便于追踪更改
### 2. 性能优化
- **按需加载**: 只加载当前所需语言,减少初始加载时间
- **缓存友好**: JSON文件可以被浏览器和CDN更好地缓存
- **压缩效率**: JSON格式压缩率通常更高
### 3. 开发体验
- **动态切换**: 支持运行时语言切换,无需重新加载页面
- **易于扩展**: 添加新语言只需增加JSON文件
- **调试友好**: 更容易定位翻译问题和缺失键
### 4. 部署便利
- **静态资源**: JSON文件可以作为静态资源部署
- **CDN支持**: 可以通过CDN分发翻译文件
- **版本管理**: 更容易管理不同版本的翻译
## 兼容性保证 (Compatibility Assurance)
- **API兼容**: 所有现有的JavaScript API保持不变
- **调用方式**: 现有代码无需修改即可工作
- **错误处理**: 增强的回退机制确保用户体验
- **性能**: 新系统性能与旧系统相当或更好
## 后续建议 (Future Recommendations)
1. **监控**: 部署后监控翻译加载性能和错误率
2. **优化**: 考虑实施翻译缓存策略以进一步提升性能
3. **扩展**: 可以考虑添加翻译管理界面,便于非技术人员更新翻译
4. **自动化**: 实施CI/CD流程自动验证翻译完整性
---
**迁移完成时间**: 2024年
**影响文件数量**: 21个文件 (9个新JSON + 2个JS更新 + 1个Python更新 + 1个服务器配置)
**翻译键总数**: 386个 × 9种语言 = 3,474个翻译条目
**测试状态**: ✅ 全部通过

1160
locales/de.json Normal file

File diff suppressed because it is too large Load Diff

1160
locales/en.json Normal file

File diff suppressed because it is too large Load Diff

1160
locales/es.json Normal file

File diff suppressed because it is too large Load Diff

1160
locales/fr.json Normal file

File diff suppressed because it is too large Load Diff

1160
locales/ja.json Normal file

File diff suppressed because it is too large Load Diff

1160
locales/ko.json Normal file

File diff suppressed because it is too large Load Diff

1160
locales/ru.json Normal file

File diff suppressed because it is too large Load Diff

1160
locales/zh-CN.json Normal file

File diff suppressed because it is too large Load Diff

1160
locales/zh-TW.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ class Config:
def __init__(self):
self.templates_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'templates')
self.static_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static')
self.i18n_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'locales')
# Path mapping dictionary, target to link mapping
self._path_mappings = {}
# Static route mapping dictionary, target to route mapping
@@ -268,19 +269,26 @@ class Config:
return []
def get_preview_static_url(self, preview_path: str) -> str:
"""Convert local preview path to static URL"""
if not preview_path:
return ""
real_path = os.path.realpath(preview_path).replace(os.sep, '/')
# Find longest matching path (most specific match)
best_match = ""
best_route = ""
for path, route in self._route_mappings.items():
if real_path.startswith(path):
relative_path = os.path.relpath(real_path, path).replace(os.sep, '/')
safe_parts = [urllib.parse.quote(part) for part in relative_path.split('/')]
safe_path = '/'.join(safe_parts)
return f'{route}/{safe_path}'
if real_path.startswith(path) and len(path) > len(best_match):
best_match = path
best_route = route
if best_match:
relative_path = os.path.relpath(real_path, best_match).replace(os.sep, '/')
safe_parts = [urllib.parse.quote(part) for part in relative_path.split('/')]
safe_path = '/'.join(safe_parts)
return f'{best_route}/{safe_path}'
return ""
# Global config instance

View File

@@ -145,7 +145,12 @@ class LoraManager:
except Exception as e:
logger.warning(f"Failed to add static route on initialization for {target_path}: {e}")
continue
# Add static route for locales JSON files
if os.path.exists(config.i18n_path):
app.router.add_static('/locales', config.i18n_path)
logger.info(f"Added static route for locales: /locales -> {config.i18n_path}")
# Add static route for plugin assets
app.router.add_static('/loras_static', config.static_path)
@@ -198,18 +203,149 @@ class LoraManager:
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
# Create low-priority initialization tasks
asyncio.create_task(lora_scanner.initialize_in_background(), name='lora_cache_init')
asyncio.create_task(checkpoint_scanner.initialize_in_background(), name='checkpoint_cache_init')
asyncio.create_task(embedding_scanner.initialize_in_background(), name='embedding_cache_init')
asyncio.create_task(recipe_scanner.initialize_in_background(), name='recipe_cache_init')
init_tasks = [
asyncio.create_task(lora_scanner.initialize_in_background(), name='lora_cache_init'),
asyncio.create_task(checkpoint_scanner.initialize_in_background(), name='checkpoint_cache_init'),
asyncio.create_task(embedding_scanner.initialize_in_background(), name='embedding_cache_init'),
asyncio.create_task(recipe_scanner.initialize_in_background(), name='recipe_cache_init')
]
await ExampleImagesMigration.check_and_run_migrations()
# Schedule post-initialization tasks to run after scanners complete
asyncio.create_task(
cls._run_post_initialization_tasks(init_tasks),
name='post_init_tasks'
)
logger.info("LoRA Manager: All services initialized and background tasks scheduled")
except Exception as e:
logger.error(f"LoRA Manager: Error initializing services: {e}", exc_info=True)
@classmethod
async def _run_post_initialization_tasks(cls, init_tasks):
"""Run post-initialization tasks after all scanners complete"""
try:
logger.debug("LoRA Manager: Waiting for scanner initialization to complete...")
# Wait for all scanner initialization tasks to complete
await asyncio.gather(*init_tasks, return_exceptions=True)
logger.debug("LoRA Manager: Scanner initialization completed, starting post-initialization tasks...")
# Run post-initialization tasks
post_tasks = [
asyncio.create_task(cls._cleanup_backup_files(), name='cleanup_bak_files'),
# Add more post-initialization tasks here as needed
# asyncio.create_task(cls._another_post_task(), name='another_task'),
]
# Run all post-initialization tasks
results = await asyncio.gather(*post_tasks, return_exceptions=True)
# Log results
for i, result in enumerate(results):
task_name = post_tasks[i].get_name()
if isinstance(result, Exception):
logger.error(f"Post-initialization task '{task_name}' failed: {result}")
else:
logger.debug(f"Post-initialization task '{task_name}' completed successfully")
logger.debug("LoRA Manager: All post-initialization tasks completed")
except Exception as e:
logger.error(f"LoRA Manager: Error in post-initialization tasks: {e}", exc_info=True)
@classmethod
async def _cleanup_backup_files(cls):
"""Clean up .bak files in all model roots"""
try:
logger.debug("Starting cleanup of .bak files in model directories...")
# Collect all model roots
all_roots = set()
all_roots.update(config.loras_roots)
all_roots.update(config.base_models_roots)
all_roots.update(config.embeddings_roots)
total_deleted = 0
total_size_freed = 0
for root_path in all_roots:
if not os.path.exists(root_path):
continue
try:
deleted_count, size_freed = await cls._cleanup_backup_files_in_directory(root_path)
total_deleted += deleted_count
total_size_freed += size_freed
if deleted_count > 0:
logger.debug(f"Cleaned up {deleted_count} .bak files in {root_path} (freed {size_freed / (1024*1024):.2f} MB)")
except Exception as e:
logger.error(f"Error cleaning up .bak files in {root_path}: {e}")
# Yield control periodically
await asyncio.sleep(0.01)
if total_deleted > 0:
logger.debug(f"Backup cleanup completed: removed {total_deleted} .bak files, freed {total_size_freed / (1024*1024):.2f} MB total")
else:
logger.debug("Backup cleanup completed: no .bak files found")
except Exception as e:
logger.error(f"Error during backup file cleanup: {e}", exc_info=True)
@classmethod
async def _cleanup_backup_files_in_directory(cls, directory_path: str):
"""Clean up .bak files in a specific directory recursively
Args:
directory_path: Path to the directory to clean
Returns:
Tuple[int, int]: (number of files deleted, total size freed in bytes)
"""
deleted_count = 0
size_freed = 0
visited_paths = set()
def cleanup_recursive(path):
nonlocal deleted_count, size_freed
try:
real_path = os.path.realpath(path)
if real_path in visited_paths:
return
visited_paths.add(real_path)
with os.scandir(path) as it:
for entry in it:
try:
if entry.is_file(follow_symlinks=True) and entry.name.endswith('.bak'):
file_size = entry.stat().st_size
os.remove(entry.path)
deleted_count += 1
size_freed += file_size
logger.debug(f"Deleted .bak file: {entry.path}")
elif entry.is_dir(follow_symlinks=True):
cleanup_recursive(entry.path)
except Exception as e:
logger.warning(f"Could not delete .bak file {entry.path}: {e}")
except Exception as e:
logger.error(f"Error scanning directory {path} for .bak files: {e}")
# Run the recursive cleanup in a thread pool to avoid blocking
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, cleanup_recursive, directory_path)
return deleted_count, size_freed
@classmethod
async def _cleanup(cls, app):
"""Cleanup resources using ServiceRegistry"""

View File

@@ -11,6 +11,7 @@ import jinja2
from ..utils.routes_common import ModelRouteUtils
from ..services.websocket_manager import ws_manager
from ..services.settings_manager import settings
from ..services.server_i18n import server_i18n
from ..utils.utils import calculate_relative_path_for_model
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
from ..config import config
@@ -48,6 +49,7 @@ class BaseModelRoutes(ABC):
app.router.add_post(f'/api/{prefix}/relink-civitai', self.relink_civitai)
app.router.add_post(f'/api/{prefix}/replace-preview', self.replace_preview)
app.router.add_post(f'/api/{prefix}/save-metadata', self.save_metadata)
app.router.add_post(f'/api/{prefix}/add-tags', self.add_tags)
app.router.add_post(f'/api/{prefix}/rename', self.rename_model)
app.router.add_post(f'/api/{prefix}/bulk-delete', self.bulk_delete_models)
app.router.add_post(f'/api/{prefix}/verify-duplicates', self.verify_duplicates)
@@ -69,6 +71,8 @@ class BaseModelRoutes(ABC):
app.router.add_get(f'/api/{prefix}/get-notes', self.get_model_notes)
app.router.add_get(f'/api/{prefix}/preview-url', self.get_model_preview_url)
app.router.add_get(f'/api/{prefix}/civitai-url', self.get_model_civitai_url)
app.router.add_get(f'/api/{prefix}/metadata', self.get_model_metadata)
app.router.add_get(f'/api/{prefix}/model-description', self.get_model_description)
# Autocomplete route
app.router.add_get(f'/api/{prefix}/relative-paths', self.get_relative_paths)
@@ -111,30 +115,36 @@ class BaseModelRoutes(ABC):
if not self.template_env or not template_name:
return web.Response(text="Template environment or template name not set", status=500)
if is_initializing:
rendered = self.template_env.get_template(template_name).render(
folders=[],
is_initializing=True,
settings=settings,
request=request
)
else:
# Get user's language setting
user_language = settings.get('language', 'en')
# Set server-side i18n locale
server_i18n.set_locale(user_language)
# Add i18n filter to the template environment if not already added
if not hasattr(self.template_env, '_i18n_filter_added'):
self.template_env.filters['t'] = server_i18n.create_template_filter()
self.template_env._i18n_filter_added = True
# Prepare template context
template_context = {
'is_initializing': is_initializing,
'settings': settings,
'request': request,
'folders': [],
't': server_i18n.get_translation,
}
if not is_initializing:
try:
cache = await self.service.scanner.get_cached_data(force_refresh=False)
rendered = self.template_env.get_template(template_name).render(
folders=getattr(cache, "folders", []),
is_initializing=False,
settings=settings,
request=request
)
template_context['folders'] = getattr(cache, "folders", [])
except Exception as cache_error:
logger.error(f"Error loading cache data: {cache_error}")
rendered = self.template_env.get_template(template_name).render(
folders=[],
is_initializing=True,
settings=settings,
request=request
)
template_context['is_initializing'] = True
rendered = self.template_env.get_template(template_name).render(**template_context)
return web.Response(
text=rendered,
content_type='text/html'
@@ -191,7 +201,7 @@ class BaseModelRoutes(ABC):
'modelname': request.query.get('search_modelname', 'true').lower() == 'true',
'tags': request.query.get('search_tags', 'false').lower() == 'true',
'creator': request.query.get('search_creator', 'false').lower() == 'true',
'recursive': request.query.get('recursive', 'false').lower() == 'true',
'recursive': request.query.get('recursive', 'true').lower() == 'true',
}
# Parse hash filters if provided
@@ -263,6 +273,10 @@ class BaseModelRoutes(ABC):
"""Handle saving metadata updates"""
return await ModelRouteUtils.handle_save_metadata(request, self.service.scanner)
async def add_tags(self, request: web.Request) -> web.Response:
"""Handle adding tags to model metadata"""
return await ModelRouteUtils.handle_add_tags(request, self.service.scanner)
async def rename_model(self, request: web.Request) -> web.Response:
"""Handle renaming a model file and its associated files"""
return await ModelRouteUtils.handle_rename_model(request, self.service.scanner)
@@ -684,19 +698,27 @@ class BaseModelRoutes(ABC):
source_dir = os.path.dirname(file_path)
if os.path.normpath(source_dir) == os.path.normpath(target_path):
logger.info(f"Source and target directories are the same: {source_dir}")
return web.json_response({'success': True, 'message': 'Source and target directories are the same'})
file_name = os.path.basename(file_path)
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
if os.path.exists(target_file_path):
return web.json_response({
'success': False,
'error': f"Target file already exists: {target_file_path}"
}, status=409)
success = await self.service.scanner.move_model(file_path, target_path)
if success:
return web.json_response({'success': True, 'new_file_path': target_file_path})
'success': True,
'message': 'Source and target directories are the same',
'original_file_path': file_path,
'new_file_path': file_path
})
new_file_path = await self.service.scanner.move_model(file_path, target_path)
if new_file_path:
return web.json_response({
'success': True,
'original_file_path': file_path,
'new_file_path': new_file_path
})
else:
return web.Response(text='Failed to move model', status=500)
return web.json_response({
'success': False,
'error': 'Failed to move model',
'original_file_path': file_path,
'new_file_path': None
}, status=500)
except Exception as e:
logger.error(f"Error moving model: {e}", exc_info=True)
return web.Response(text=str(e), status=500)
@@ -715,26 +737,28 @@ class BaseModelRoutes(ABC):
source_dir = os.path.dirname(file_path)
if os.path.normpath(source_dir) == os.path.normpath(target_path):
results.append({
"path": file_path,
"original_file_path": file_path,
"new_file_path": file_path,
"success": True,
"message": "Source and target directories are the same"
})
continue
file_name = os.path.basename(file_path)
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
if os.path.exists(target_file_path):
new_file_path = await self.service.scanner.move_model(file_path, target_path)
if new_file_path:
results.append({
"path": file_path,
"success": False,
"message": f"Target file already exists: {target_file_path}"
"original_file_path": file_path,
"new_file_path": new_file_path,
"success": True,
"message": "Success"
})
else:
results.append({
"original_file_path": file_path,
"new_file_path": None,
"success": False,
"message": "Failed to move model"
})
continue
success = await self.service.scanner.move_model(file_path, target_path)
results.append({
"path": file_path,
"success": success,
"message": "Success" if success else "Failed to move model"
})
success_count = sum(1 for r in results if r["success"])
failure_count = len(results) - success_count
return web.json_response({
@@ -1128,6 +1152,58 @@ class BaseModelRoutes(ABC):
'error': str(e)
}, status=500)
async def get_model_metadata(self, request: web.Request) -> web.Response:
"""Get filtered CivitAI metadata for a model by file path"""
try:
file_path = request.query.get('file_path')
if not file_path:
return web.Response(text='File path is required', status=400)
metadata = await self.service.get_model_metadata(file_path)
if metadata is not None:
return web.json_response({
'success': True,
'metadata': metadata
})
else:
return web.json_response({
'success': False,
'error': f'{self.model_type.capitalize()} not found or no CivitAI metadata available'
}, status=404)
except Exception as e:
logger.error(f"Error getting {self.model_type} metadata: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_model_description(self, request: web.Request) -> web.Response:
"""Get model description by file path"""
try:
file_path = request.query.get('file_path')
if not file_path:
return web.Response(text='File path is required', status=400)
description = await self.service.get_model_description(file_path)
if description is not None:
return web.json_response({
'success': True,
'description': description
})
else:
return web.json_response({
'success': False,
'error': f'{self.model_type.capitalize()} not found or no description available'
}, status=404)
except Exception as e:
logger.error(f"Error getting {self.model_type} description: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_relative_paths(self, request: web.Request) -> web.Response:
"""Get model relative file paths for autocomplete functionality"""
try:

View File

@@ -44,7 +44,6 @@ class LoraRoutes(BaseModelRoutes):
# LoRA-specific query routes
app.router.add_get(f'/api/{prefix}/letter-counts', self.get_letter_counts)
app.router.add_get(f'/api/{prefix}/get-trigger-words', self.get_lora_trigger_words)
app.router.add_get(f'/api/{prefix}/model-description', self.get_lora_model_description)
app.router.add_get(f'/api/{prefix}/usage-tips-by-path', self.get_lora_usage_tips_by_path)
# CivitAI integration with LoRA-specific validation
@@ -298,74 +297,6 @@ class LoraRoutes(BaseModelRoutes):
"error": str(e)
}, status=500)
async def get_lora_model_description(self, request: web.Request) -> web.Response:
"""Get model description for a Lora model"""
try:
# Get parameters
model_id = request.query.get('model_id')
file_path = request.query.get('file_path')
if not model_id:
return web.json_response({
'success': False,
'error': 'Model ID is required'
}, status=400)
# Check if we already have the description stored in metadata
description = None
tags = []
creator = {}
if file_path:
import os
from ..utils.metadata_manager import MetadataManager
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
description = metadata.get('modelDescription')
tags = metadata.get('tags', [])
creator = metadata.get('creator', {})
# If description is not in metadata, fetch from CivitAI
if not description:
logger.info(f"Fetching model metadata for model ID: {model_id}")
model_metadata, _ = await self.civitai_client.get_model_metadata(model_id)
if model_metadata:
description = model_metadata.get('description')
tags = model_metadata.get('tags', [])
creator = model_metadata.get('creator', {})
# Save the metadata to file if we have a file path and got metadata
if file_path:
try:
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
metadata['modelDescription'] = description
metadata['tags'] = tags
# Ensure the civitai dict exists
if 'civitai' not in metadata:
metadata['civitai'] = {}
# Store creator in the civitai nested structure
metadata['civitai']['creator'] = creator
await MetadataManager.save_metadata(file_path, metadata, True)
except Exception as e:
logger.error(f"Error saving model metadata: {e}")
return web.json_response({
'success': True,
'description': description or "<p>No model description available.</p>",
'tags': tags,
'creator': creator
})
except Exception as e:
logger.error(f"Error getting model metadata: {e}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_trigger_words(self, request: web.Request) -> web.Response:
"""Get trigger words for specified LoRA models"""
try:

View File

@@ -17,6 +17,7 @@ from ..recipes import RecipeParserFactory
from ..utils.constants import CARD_PREVIEW_WIDTH
from ..services.settings_manager import settings
from ..services.server_i18n import server_i18n
from ..config import config
# Check if running in standalone mode
@@ -127,6 +128,17 @@ class RecipeRoutes:
# Ensure services are initialized
await self.init_services()
# 获取用户语言设置
user_language = settings.get('language', 'en')
# 设置服务端i18n语言
server_i18n.set_locale(user_language)
# 为模板环境添加i18n过滤器
if not hasattr(self.template_env, '_i18n_filter_added'):
self.template_env.filters['t'] = server_i18n.create_template_filter()
self.template_env._i18n_filter_added = True
# Skip initialization check and directly try to get cached data
try:
# Recipe scanner will initialize cache if needed
@@ -136,7 +148,9 @@ class RecipeRoutes:
recipes=[], # Frontend will load recipes via API
is_initializing=False,
settings=settings,
request=request
request=request,
# 添加服务端翻译函数
t=server_i18n.get_translation,
)
except Exception as cache_error:
logger.error(f"Error loading recipe cache data: {cache_error}")
@@ -145,7 +159,9 @@ class RecipeRoutes:
rendered = template.render(
is_initializing=True,
settings=settings,
request=request
request=request,
# 添加服务端翻译函数
t=server_i18n.get_translation,
)
logger.info("Recipe cache error, returning initialization page")

View File

@@ -9,6 +9,7 @@ from typing import Dict, List, Any
from ..config import config
from ..services.settings_manager import settings
from ..services.server_i18n import server_i18n
from ..services.service_registry import ServiceRegistry
from ..utils.usage_stats import UsageStats
@@ -58,11 +59,23 @@ class StatsRoutes:
is_initializing = lora_initializing or checkpoint_initializing or embedding_initializing
# 获取用户语言设置
user_language = settings.get('language', 'en')
# 设置服务端i18n语言
server_i18n.set_locale(user_language)
# 为模板环境添加i18n过滤器
if not hasattr(self.template_env, '_i18n_filter_added'):
self.template_env.filters['t'] = server_i18n.create_template_filter()
self.template_env._i18n_filter_added = True
template = self.template_env.get_template('statistics.html')
rendered = template.render(
is_initializing=is_initializing,
settings=settings,
request=request
request=request,
t=server_i18n.get_translation,
)
return web.Response(

View File

@@ -4,6 +4,7 @@ import logging
import os
from ..utils.models import BaseModelMetadata
from ..utils.routes_common import ModelRouteUtils
from ..utils.constants import NSFW_LEVELS
from .settings_manager import settings
from ..utils.utils import fuzzy_match
@@ -68,7 +69,7 @@ class BaseModelService(ABC):
'filename': True,
'modelname': True,
'tags': False,
'recursive': False,
'recursive': True,
}
# Get the base data set using new sort logic
@@ -139,12 +140,20 @@ class BaseModelService(ABC):
# Apply folder filtering
if folder is not None:
if search_options and search_options.get('recursive', False):
if search_options and search_options.get('recursive', True):
# Recursive folder filtering - include all subfolders
data = [
item for item in data
if item['folder'].startswith(folder)
]
# Ensure we match exact folder or its subfolders by checking path boundaries
if folder == "":
# Empty folder means root - include all items
pass # Don't filter anything
else:
# Add trailing slash to ensure we match folder boundaries correctly
folder_with_separator = folder + "/"
data = [
item for item in data
if (item['folder'] == folder or
item['folder'].startswith(folder_with_separator))
]
else:
# Exact folder filtering
data = [
@@ -379,6 +388,26 @@ class BaseModelService(ABC):
return {'civitai_url': None, 'model_id': None, 'version_id': None}
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
"""Get filtered CivitAI metadata for a model by file path"""
cache = await self.scanner.get_cached_data()
for model in cache.raw_data:
if model.get('file_path') == file_path:
return ModelRouteUtils.filter_civitai_data(model.get("civitai", {}))
return None
async def get_model_description(self, file_path: str) -> Optional[str]:
"""Get model description by file path"""
cache = await self.scanner.get_cached_data()
for model in cache.raw_data:
if model.get('file_path') == file_path:
return model.get('modelDescription', '')
return None
async def search_relative_paths(self, search_term: str, limit: int = 15) -> List[str]:
"""Search model relative file paths for autocomplete functionality"""
cache = await self.scanner.get_cached_data()

View File

@@ -34,12 +34,11 @@ class CheckpointService(BaseModelService):
"file_size": checkpoint_data.get("size", 0),
"modified": checkpoint_data.get("modified", ""),
"tags": checkpoint_data.get("tags", []),
"modelDescription": checkpoint_data.get("modelDescription", ""),
"from_civitai": checkpoint_data.get("from_civitai", True),
"notes": checkpoint_data.get("notes", ""),
"model_type": checkpoint_data.get("model_type", "checkpoint"),
"favorite": checkpoint_data.get("favorite", False),
"civitai": ModelRouteUtils.filter_civitai_data(checkpoint_data.get("civitai", {}))
"civitai": ModelRouteUtils.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
}
def find_duplicate_hashes(self) -> Dict:

View File

@@ -193,20 +193,6 @@ class CivitaiClient:
else:
logger.error(f"Download failed for {url} with status {response.status}")
return False, f"Download failed with status {response.status}"
# Get filename from content-disposition header (only on first attempt)
if retry_count == 0:
content_disposition = response.headers.get('Content-Disposition')
parsed_filename = self._parse_content_disposition(content_disposition)
if parsed_filename:
filename = parsed_filename
# Update paths with correct filename
save_path = os.path.join(save_dir, filename)
new_part_path = save_path + '.part'
# Rename existing part file if filename changed
if part_path != new_part_path and os.path.exists(part_path):
os.rename(part_path, new_part_path)
part_path = new_part_path
# Get total file size for progress calculation (if not set from Content-Range)
if total_size == 0:

View File

@@ -389,13 +389,39 @@ class DownloadManager:
return formatted_path
async def _execute_download(self, download_url: str, save_dir: str,
metadata, version_info: Dict,
relative_path: str, progress_callback=None,
model_type: str = "lora", download_id: str = None) -> Dict:
metadata, version_info: Dict,
relative_path: str, progress_callback=None,
model_type: str = "lora", download_id: str = None) -> Dict:
"""Execute the actual download process including preview images and model files"""
try:
civitai_client = await self._get_civitai_client()
save_path = metadata.file_path
# Extract original filename details
original_filename = os.path.basename(metadata.file_path)
base_name, extension = os.path.splitext(original_filename)
# Check for filename conflicts and generate unique filename if needed
# Use the hash from metadata for conflict resolution
def hash_provider():
return metadata.sha256
unique_filename = metadata.generate_unique_filename(
save_dir,
base_name,
extension,
hash_provider=hash_provider
)
# Update paths if filename changed
if unique_filename != original_filename:
logger.info(f"Filename conflict detected. Changing '{original_filename}' to '{unique_filename}'")
save_path = os.path.join(save_dir, unique_filename)
# Update metadata with new file path and name
metadata.file_path = save_path.replace(os.sep, '/')
metadata.file_name = os.path.splitext(unique_filename)[0]
else:
save_path = metadata.file_path
part_path = save_path + '.part'
metadata_path = os.path.splitext(save_path)[0] + '.metadata.json'
@@ -491,7 +517,7 @@ class DownloadManager:
metadata.update_file_info(save_path)
# 5. Final metadata update
await MetadataManager.save_metadata(save_path, metadata, True)
await MetadataManager.save_metadata(save_path, metadata)
# 6. Update cache based on model type
if model_type == "checkpoint":

View File

@@ -34,12 +34,11 @@ class EmbeddingService(BaseModelService):
"file_size": embedding_data.get("size", 0),
"modified": embedding_data.get("modified", ""),
"tags": embedding_data.get("tags", []),
"modelDescription": embedding_data.get("modelDescription", ""),
"from_civitai": embedding_data.get("from_civitai", True),
"notes": embedding_data.get("notes", ""),
"model_type": embedding_data.get("model_type", "embedding"),
"favorite": embedding_data.get("favorite", False),
"civitai": ModelRouteUtils.filter_civitai_data(embedding_data.get("civitai", {}))
"civitai": ModelRouteUtils.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
}
def find_duplicate_hashes(self) -> Dict:

View File

@@ -34,12 +34,11 @@ class LoraService(BaseModelService):
"file_size": lora_data.get("size", 0),
"modified": lora_data.get("modified", ""),
"tags": lora_data.get("tags", []),
"modelDescription": lora_data.get("modelDescription", ""),
"from_civitai": lora_data.get("from_civitai", True),
"usage_tips": lora_data.get("usage_tips", ""),
"notes": lora_data.get("notes", ""),
"favorite": lora_data.get("favorite", False),
"civitai": ModelRouteUtils.filter_civitai_data(lora_data.get("civitai", {}))
"civitai": ModelRouteUtils.filter_civitai_data(lora_data.get("civitai", {}), minimal=True)
}
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:

View File

@@ -8,7 +8,7 @@ from typing import List, Dict, Optional, Type, Set
from ..utils.models import BaseModelMetadata
from ..config import config
from ..utils.file_utils import find_preview_file
from ..utils.file_utils import find_preview_file, get_preview_extension
from ..utils.metadata_manager import MetadataManager
from .model_cache import ModelCache
from .model_hash_index import ModelHashIndex
@@ -585,6 +585,7 @@ class ModelScanner:
if entry.is_file(follow_symlinks=True) and any(entry.name.endswith(ext) for ext in self.file_extensions):
file_path = entry.path.replace(os.sep, "/")
result = await self._process_model_file(file_path, original_root)
# Only add to models if result is not None (skip corrupted metadata)
if result:
models.append(result)
await asyncio.sleep(0)
@@ -624,7 +625,12 @@ class ModelScanner:
async def _process_model_file(self, file_path: str, root_path: str) -> Dict:
"""Process a single model file and return its metadata"""
metadata = await MetadataManager.load_metadata(file_path, self.model_class)
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.model_class)
if should_skip:
# Metadata file exists but cannot be parsed - skip this model
logger.warning(f"Skipping model {file_path} due to corrupted metadata file")
return None
if metadata is None:
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
@@ -640,7 +646,7 @@ class ModelScanner:
metadata = self.model_class.from_civitai_info(version_info, file_info, file_path)
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
await MetadataManager.save_metadata(file_path, metadata, True)
await MetadataManager.save_metadata(file_path, metadata)
logger.debug(f"Created metadata from .civitai.info for {file_path}")
except Exception as e:
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
@@ -667,7 +673,7 @@ class ModelScanner:
metadata.modelDescription = version_info['model']['description']
# Save the updated metadata
await MetadataManager.save_metadata(file_path, metadata, True)
await MetadataManager.save_metadata(file_path, metadata)
logger.debug(f"Updated metadata with civitai info for {file_path}")
except Exception as e:
logger.error(f"Error restoring civitai data from .civitai.info for {file_path}: {e}")
@@ -747,7 +753,7 @@ class ModelScanner:
model_data['civitai']['creator'] = model_metadata['creator']
await MetadataManager.save_metadata(file_path, model_data, True)
await MetadataManager.save_metadata(file_path, model_data)
except Exception as e:
logger.error(f"Failed to update metadata from Civitai for {file_path}: {e}")
@@ -786,8 +792,16 @@ class ModelScanner:
logger.error(f"Error adding model to cache: {e}")
return False
async def move_model(self, source_path: str, target_path: str) -> bool:
"""Move a model and its associated files to a new location"""
async def move_model(self, source_path: str, target_path: str) -> Optional[str]:
"""Move a model and its associated files to a new location
Args:
source_path: Original file path
target_path: Target directory path
Returns:
Optional[str]: New file path if successful, None if failed
"""
try:
source_path = source_path.replace(os.sep, '/')
target_path = target_path.replace(os.sep, '/')
@@ -796,14 +810,28 @@ class ModelScanner:
if not file_ext or file_ext.lower() not in self.file_extensions:
logger.error(f"Invalid file extension for model: {file_ext}")
return False
return None
base_name = os.path.splitext(os.path.basename(source_path))[0]
source_dir = os.path.dirname(source_path)
os.makedirs(target_path, exist_ok=True)
target_file = os.path.join(target_path, f"{base_name}{file_ext}").replace(os.sep, '/')
def get_source_hash():
return self.get_hash_by_path(source_path)
# Check for filename conflicts and auto-rename if necessary
from ..utils.models import BaseModelMetadata
final_filename = BaseModelMetadata.generate_unique_filename(
target_path, base_name, file_ext, get_source_hash
)
target_file = os.path.join(target_path, final_filename).replace(os.sep, '/')
final_base_name = os.path.splitext(final_filename)[0]
# Log if filename was changed due to conflict
if final_filename != f"{base_name}{file_ext}":
logger.info(f"Renamed {base_name}{file_ext} to {final_filename} to avoid filename conflict")
real_source = os.path.realpath(source_path)
real_target = os.path.realpath(target_file)
@@ -820,12 +848,17 @@ class ModelScanner:
for file in os.listdir(source_dir):
if file.startswith(base_name + ".") and file != os.path.basename(source_path):
source_file_path = os.path.join(source_dir, file)
# Generate new filename with the same base name as the model file
file_suffix = file[len(base_name):] # Get the part after base_name (e.g., ".metadata.json", ".preview.png")
new_associated_filename = f"{final_base_name}{file_suffix}"
target_associated_path = os.path.join(target_path, new_associated_filename)
# Store metadata file path for special handling
if file == f"{base_name}.metadata.json":
source_metadata = source_file_path
moved_metadata_path = os.path.join(target_path, file)
moved_metadata_path = target_associated_path
else:
files_to_move.append((source_file_path, os.path.join(target_path, file)))
files_to_move.append((source_file_path, target_associated_path))
except Exception as e:
logger.error(f"Error listing files in {source_dir}: {e}")
@@ -847,11 +880,11 @@ class ModelScanner:
await self.update_single_model_cache(source_path, target_file, metadata)
return True
return target_file
except Exception as e:
logger.error(f"Error moving model: {e}", exc_info=True)
return False
return None
async def _update_metadata_paths(self, metadata_path: str, model_path: str) -> Dict:
"""Update file paths in metadata file"""
@@ -860,12 +893,15 @@ class ModelScanner:
metadata = json.load(f)
metadata['file_path'] = model_path.replace(os.sep, '/')
# Update file_name to match the new filename
metadata['file_name'] = os.path.splitext(os.path.basename(model_path))[0]
if 'preview_url' in metadata and metadata['preview_url']:
preview_dir = os.path.dirname(model_path)
preview_name = os.path.splitext(os.path.basename(metadata['preview_url']))[0]
preview_ext = os.path.splitext(metadata['preview_url'])[1]
new_preview_path = os.path.join(preview_dir, f"{preview_name}{preview_ext}")
# Update preview filename to match the new base name
new_base_name = os.path.splitext(os.path.basename(model_path))[0]
preview_ext = get_preview_extension(metadata['preview_url'])
new_preview_path = os.path.join(preview_dir, f"{new_base_name}{preview_ext}")
metadata['preview_url'] = new_preview_path.replace(os.sep, '/')
await MetadataManager.save_metadata(metadata_path, metadata)
@@ -932,8 +968,16 @@ class ModelScanner:
def get_hash_by_path(self, file_path: str) -> Optional[str]:
"""Get hash for a model by its file path"""
return self._hash_index.get_hash(file_path)
if self._cache is None or not self._cache.raw_data:
return None
# Iterate through cache data to find matching file path
for model_data in self._cache.raw_data:
if model_data.get('file_path') == file_path:
return model_data.get('sha256')
return None
def get_hash_by_filename(self, filename: str) -> Optional[str]:
"""Get hash for a model by its filename without path"""
return self._hash_index.get_hash_by_filename(filename)

114
py/services/server_i18n.py Normal file
View File

@@ -0,0 +1,114 @@
import os
import json
import logging
from typing import Dict, Any, Optional
logger = logging.getLogger(__name__)
class ServerI18nManager:
"""Server-side internationalization manager for template rendering"""
def __init__(self):
self.translations = {}
self.current_locale = 'en'
self._load_translations()
def _load_translations(self):
"""Load all translation files from the locales directory"""
i18n_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
'locales'
)
if not os.path.exists(i18n_path):
logger.warning(f"I18n directory not found: {i18n_path}")
return
# Load all available locale files
for filename in os.listdir(i18n_path):
if filename.endswith('.json'):
locale_code = filename[:-5] # Remove .json extension
try:
self._load_locale_file(i18n_path, filename, locale_code)
except Exception as e:
logger.error(f"Error loading locale file {filename}: {e}")
def _load_locale_file(self, path: str, filename: str, locale_code: str):
"""Load a single locale JSON file"""
file_path = os.path.join(path, filename)
try:
with open(file_path, 'r', encoding='utf-8') as f:
translations = json.load(f)
self.translations[locale_code] = translations
logger.debug(f"Loaded translations for {locale_code} from {filename}")
except Exception as e:
logger.error(f"Error parsing locale file {filename}: {e}")
def set_locale(self, locale: str):
"""Set the current locale"""
if locale in self.translations:
self.current_locale = locale
else:
logger.warning(f"Locale {locale} not found, using 'en'")
self.current_locale = 'en'
def get_translation(self, key: str, params: Dict[str, Any] = None, **kwargs) -> str:
"""Get translation for a key with optional parameters (supports both dict and keyword args)"""
# Merge kwargs into params for convenience
if params is None:
params = {}
if kwargs:
params = {**params, **kwargs}
if self.current_locale not in self.translations:
return key
# Navigate through nested object using dot notation
keys = key.split('.')
value = self.translations[self.current_locale]
for k in keys:
if isinstance(value, dict) and k in value:
value = value[k]
else:
# Fallback to English if current locale doesn't have the key
if self.current_locale != 'en' and 'en' in self.translations:
en_value = self.translations['en']
for k in keys:
if isinstance(en_value, dict) and k in en_value:
en_value = en_value[k]
else:
return key
value = en_value
else:
return key
break
if not isinstance(value, str):
return key
# Replace parameters if provided
if params:
for param_key, param_value in params.items():
placeholder = f"{{{param_key}}}"
double_placeholder = f"{{{{{param_key}}}}}"
value = value.replace(placeholder, str(param_value))
value = value.replace(double_placeholder, str(param_value))
return value
def get_available_locales(self) -> list:
"""Get list of available locales"""
return list(self.translations.keys())
def create_template_filter(self):
"""Create a Jinja2 filter function for templates"""
def t_filter(key: str, **params) -> str:
return self.get_translation(key, params)
return t_filter
# Create global instance
server_i18n = ServerI18nManager()

View File

@@ -80,7 +80,8 @@ class SettingsManager:
"""Return default settings"""
return {
"civitai_api_key": "",
"show_only_sfw": False
"show_only_sfw": False,
"language": "en" # 添加默认语言设置
}
def get(self, key: str, default: Any = None) -> Any:

View File

@@ -68,6 +68,7 @@ class DownloadManager:
optimize = data.get('optimize', True)
model_types = data.get('model_types', ['lora', 'checkpoint'])
delay = float(data.get('delay', 0.2)) # Default to 0.2 seconds
delay = 0 # Temporary: Disable delay to speed up downloads
if not output_dir:
return web.json_response({

View File

@@ -27,39 +27,58 @@ def find_preview_file(base_name: str, dir_path: str) -> str:
full_pattern = os.path.join(dir_path, f"{base_name}{ext}")
if os.path.exists(full_pattern):
# Check if this is an image and not already webp
if ext.lower().endswith(('.jpg', '.jpeg', '.png')) and not ext.lower().endswith('.webp'):
try:
# Optimize the image to webp format
webp_path = os.path.join(dir_path, f"{base_name}.webp")
# TODO: disable the optimization for now, maybe add a config option later
# if ext.lower().endswith(('.jpg', '.jpeg', '.png')) and not ext.lower().endswith('.webp'):
# try:
# # Optimize the image to webp format
# webp_path = os.path.join(dir_path, f"{base_name}.webp")
# Use ExifUtils to optimize the image
with open(full_pattern, 'rb') as f:
image_data = f.read()
# # Use ExifUtils to optimize the image
# with open(full_pattern, 'rb') as f:
# image_data = f.read()
optimized_data, _ = ExifUtils.optimize_image(
image_data=image_data,
target_width=CARD_PREVIEW_WIDTH,
format='webp',
quality=85,
preserve_metadata=False
)
# optimized_data, _ = ExifUtils.optimize_image(
# image_data=image_data,
# target_width=CARD_PREVIEW_WIDTH,
# format='webp',
# quality=85,
# preserve_metadata=False
# )
# Save the optimized webp file
with open(webp_path, 'wb') as f:
f.write(optimized_data)
# # Save the optimized webp file
# with open(webp_path, 'wb') as f:
# f.write(optimized_data)
logger.debug(f"Optimized preview image from {full_pattern} to {webp_path}")
return webp_path.replace(os.sep, "/")
except Exception as e:
logger.error(f"Error optimizing preview image {full_pattern}: {e}")
# Fall back to original file if optimization fails
return full_pattern.replace(os.sep, "/")
# logger.debug(f"Optimized preview image from {full_pattern} to {webp_path}")
# return webp_path.replace(os.sep, "/")
# except Exception as e:
# logger.error(f"Error optimizing preview image {full_pattern}: {e}")
# # Fall back to original file if optimization fails
# return full_pattern.replace(os.sep, "/")
# Return the original path for webp images or non-image files
return full_pattern.replace(os.sep, "/")
return ""
def get_preview_extension(preview_path: str) -> str:
"""Get the complete preview extension from a preview file path
Args:
preview_path: Path to the preview file
Returns:
str: The complete extension (e.g., '.preview.png', '.png', '.webp')
"""
preview_path_lower = preview_path.lower()
# Check for compound extensions first (longer matches first)
for ext in sorted(PREVIEW_EXTENSIONS, key=len, reverse=True):
if preview_path_lower.endswith(ext.lower()):
return ext
return os.path.splitext(preview_path)[1]
def normalize_path(path: str) -> str:
"""Normalize file path to use forward slashes"""
return path.replace(os.sep, "/") if path else path

View File

@@ -1,7 +1,6 @@
from datetime import datetime
import os
import json
import shutil
import logging
from typing import Dict, Optional, Type, Union
@@ -17,7 +16,7 @@ class MetadataManager:
This class is responsible for:
1. Loading metadata safely with fallback mechanisms
2. Saving metadata with atomic operations and backups
2. Saving metadata with atomic operations
3. Creating default metadata for models
4. Handling unknown fields gracefully
"""
@@ -25,81 +24,44 @@ class MetadataManager:
@staticmethod
async def load_metadata(file_path: str, model_class: Type[BaseModelMetadata] = LoraMetadata) -> Optional[BaseModelMetadata]:
"""
Load metadata with robust error handling and data preservation.
Load metadata safely.
Args:
file_path: Path to the model file
model_class: Class to instantiate (LoraMetadata, CheckpointMetadata, etc.)
Returns:
BaseModelMetadata instance or None if file doesn't exist
tuple: (metadata, should_skip)
- metadata: BaseModelMetadata instance or None
- should_skip: True if corrupted metadata file exists and model should be skipped
"""
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
backup_path = f"{metadata_path}.bak"
# Try loading the main metadata file
if os.path.exists(metadata_path):
try:
with open(metadata_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# Create model instance
metadata = model_class.from_dict(data)
# Normalize paths
await MetadataManager._normalize_metadata_paths(metadata, file_path)
return metadata
except json.JSONDecodeError:
# JSON parsing error - try to restore from backup
logger.warning(f"Invalid JSON in metadata file: {metadata_path}")
return await MetadataManager._restore_from_backup(backup_path, file_path, model_class)
except Exception as e:
# Other errors might be due to unknown fields or schema changes
logger.error(f"Error loading metadata from {metadata_path}: {str(e)}")
return await MetadataManager._restore_from_backup(backup_path, file_path, model_class)
# Check if metadata file exists
if not os.path.exists(metadata_path):
return None, False
return None
@staticmethod
async def _restore_from_backup(backup_path: str, file_path: str, model_class: Type[BaseModelMetadata]) -> Optional[BaseModelMetadata]:
"""
Try to restore metadata from backup file
Args:
backup_path: Path to backup file
file_path: Path to the original model file
model_class: Class to instantiate
try:
with open(metadata_path, 'r', encoding='utf-8') as f:
data = json.load(f)
Returns:
BaseModelMetadata instance or None if restoration fails
"""
if os.path.exists(backup_path):
try:
logger.info(f"Attempting to restore metadata from backup: {backup_path}")
with open(backup_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# Create model instance
metadata = model_class.from_dict(data)
# Process data similarly to normal loading
metadata = model_class.from_dict(data)
await MetadataManager._normalize_metadata_paths(metadata, file_path)
return metadata
except Exception as e:
logger.error(f"Failed to restore from backup: {str(e)}")
return None
# Normalize paths
await MetadataManager._normalize_metadata_paths(metadata, file_path)
return metadata, False
except (json.JSONDecodeError, Exception) as e:
error_type = "Invalid JSON" if isinstance(e, json.JSONDecodeError) else "Parse error"
logger.error(f"{error_type} in metadata file: {metadata_path}. Error: {str(e)}. Skipping model to preserve existing data.")
return None, True # should_skip = True
@staticmethod
async def save_metadata(path: str, metadata: Union[BaseModelMetadata, Dict], create_backup: bool = False) -> bool:
async def save_metadata(path: str, metadata: Union[BaseModelMetadata, Dict]) -> bool:
"""
Save metadata with atomic write operations and backup creation.
Save metadata with atomic write operations.
Args:
path: Path to the model file or directly to the metadata file
metadata: Metadata to save (either BaseModelMetadata object or dict)
create_backup: Whether to create a new backup of existing file if a backup doesn't already exist
Returns:
bool: Success or failure
@@ -112,19 +74,8 @@ class MetadataManager:
file_path = path
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
temp_path = f"{metadata_path}.tmp"
backup_path = f"{metadata_path}.bak"
try:
# Create backup if file exists and either:
# 1. create_backup is True, OR
# 2. backup file doesn't already exist
if os.path.exists(metadata_path) and (create_backup or not os.path.exists(backup_path)):
try:
shutil.copy2(metadata_path, backup_path)
logger.debug(f"Created metadata backup at: {backup_path}")
except Exception as e:
logger.warning(f"Failed to create metadata backup: {str(e)}")
# Convert to dict if needed
if isinstance(metadata, BaseModelMetadata):
metadata_dict = metadata.to_dict()
@@ -240,7 +191,7 @@ class MetadataManager:
# await MetadataManager._enrich_metadata(metadata, real_path)
# Save the created metadata
await MetadataManager.save_metadata(file_path, metadata, create_backup=False)
await MetadataManager.save_metadata(file_path, metadata)
return metadata
@@ -310,4 +261,4 @@ class MetadataManager:
# If path attributes were changed, save the metadata back to disk
if need_update:
await MetadataManager.save_metadata(file_path, metadata, create_backup=False)
await MetadataManager.save_metadata(file_path, metadata)

View File

@@ -83,6 +83,50 @@ class BaseModelMetadata:
self.size = os.path.getsize(file_path)
self.modified = os.path.getmtime(file_path)
self.file_path = file_path.replace(os.sep, '/')
# Update file_name when file_path changes
self.file_name = os.path.splitext(os.path.basename(file_path))[0]
@staticmethod
def generate_unique_filename(target_dir: str, base_name: str, extension: str, hash_provider: callable = None) -> str:
"""Generate a unique filename to avoid conflicts
Args:
target_dir: Target directory path
base_name: Base filename without extension
extension: File extension including the dot
hash_provider: A callable that returns the SHA256 hash when needed
Returns:
str: Unique filename that doesn't conflict with existing files
"""
original_filename = f"{base_name}{extension}"
target_path = os.path.join(target_dir, original_filename)
# If no conflict, return original filename
if not os.path.exists(target_path):
return original_filename
# Only compute hash when needed
if hash_provider:
sha256_hash = hash_provider()
else:
sha256_hash = "0000"
# Generate short hash (first 4 characters of SHA256)
short_hash = sha256_hash[:4] if sha256_hash else "0000"
# Try with short hash suffix
unique_filename = f"{base_name}-{short_hash}{extension}"
unique_path = os.path.join(target_dir, unique_filename)
# If still conflicts, add incremental number
counter = 1
while os.path.exists(unique_path):
unique_filename = f"{base_name}-{short_hash}-{counter}{extension}"
unique_path = os.path.join(target_dir, unique_filename)
counter += 1
return unique_filename
@dataclass
class LoraMetadata(BaseModelMetadata):

View File

@@ -156,7 +156,7 @@ class ModelRouteUtils:
local_metadata['preview_nsfw_level'] = first_preview.get('nsfwLevel', 0)
# Save updated metadata
await MetadataManager.save_metadata(metadata_path, local_metadata, True)
await MetadataManager.save_metadata(metadata_path, local_metadata)
@staticmethod
async def fetch_and_update_model(
@@ -229,13 +229,13 @@ class ModelRouteUtils:
await client.close()
@staticmethod
def filter_civitai_data(data: Dict) -> Dict:
def filter_civitai_data(data: Dict, minimal: bool = False) -> Dict:
"""Filter relevant fields from CivitAI data"""
if not data:
return {}
fields = [
"id", "modelId", "name", "createdAt", "updatedAt",
fields = ["id", "modelId", "name", "trainedWords"] if minimal else [
"id", "modelId", "name", "createdAt", "updatedAt",
"publishedAt", "trainedWords", "baseModel", "description",
"model", "images", "customImages", "creator"
]
@@ -870,11 +870,11 @@ class ModelRouteUtils:
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
# Compare hashes
stored_hash = metadata.get('sha256', '').lower()
stored_hash = metadata.get('sha256', '').lower();
# Set expected hash from first file if not yet set
if not expected_hash:
expected_hash = stored_hash
expected_hash = stored_hash;
# Check if hash matches expected hash
if actual_hash != expected_hash:
@@ -978,10 +978,11 @@ class ModelRouteUtils:
if os.path.exists(metadata_path):
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
hash_value = metadata.get('sha256')
logger.info(f"hash_value: {hash_value}, metadata_path: {metadata_path}, metadata: {metadata}")
# Rename all files
renamed_files = []
new_metadata_path = None
new_preview = None
for old_path, pattern in existing_files:
# Get the file extension like .safetensors or .metadata.json
@@ -1092,3 +1093,63 @@ class ModelRouteUtils:
except Exception as e:
logger.error(f"Error saving metadata: {e}", exc_info=True)
return web.Response(text=str(e), status=500)
@staticmethod
async def handle_add_tags(request: web.Request, scanner) -> web.Response:
"""Handle adding tags to model metadata
Args:
request: The aiohttp request
scanner: The model scanner instance
Returns:
web.Response: The HTTP response
"""
try:
data = await request.json()
file_path = data.get('file_path')
new_tags = data.get('tags', [])
if not file_path:
return web.Response(text='File path is required', status=400)
if not isinstance(new_tags, list):
return web.Response(text='Tags must be a list', status=400)
# Get metadata file path
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
# Load existing metadata
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
# Get existing tags (case insensitive)
existing_tags = metadata.get('tags', [])
existing_tags_lower = [tag.lower() for tag in existing_tags]
# Add new tags that don't already exist (case insensitive check)
tags_added = []
for tag in new_tags:
if isinstance(tag, str) and tag.strip():
tag_stripped = tag.strip()
if tag_stripped.lower() not in existing_tags_lower:
existing_tags.append(tag_stripped)
existing_tags_lower.append(tag_stripped.lower())
tags_added.append(tag_stripped)
# Update metadata with combined tags
metadata['tags'] = existing_tags
# Save updated metadata
await MetadataManager.save_metadata(file_path, metadata)
# Update cache
await scanner.update_single_model_cache(file_path, file_path, metadata)
return web.json_response({
'success': True,
'tags': existing_tags
})
except Exception as e:
logger.error(f"Error adding tags: {e}", exc_info=True)
return web.Response(text=str(e), status=500)

View File

@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "0.8.30"
version = "0.9.1"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",

View File

@@ -0,0 +1,305 @@
#!/usr/bin/env python3
"""
Translation Key Synchronization Script
This script synchronizes new translation keys from en.json to all other locale files
while maintaining exact formatting consistency to pass test_i18n.py validation.
Features:
- Preserves exact line-by-line formatting
- Maintains proper indentation and structure
- Adds missing keys with placeholder translations
- Handles nested objects correctly
- Ensures all locale files have identical structure
Usage:
python scripts/sync_translation_keys.py [--dry-run] [--verbose]
"""
import os
import sys
import json
import re
import argparse
from typing import Dict, List, Set, Tuple, Any, Optional
from collections import OrderedDict
# Add the parent directory to the path so we can import modules if needed
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
class TranslationKeySynchronizer:
"""Synchronizes translation keys across locale files while maintaining formatting."""
def __init__(self, locales_dir: str, verbose: bool = False):
self.locales_dir = locales_dir
self.verbose = verbose
self.reference_locale = 'en'
self.target_locales = ['zh-CN', 'zh-TW', 'ja', 'ru', 'de', 'fr', 'es', 'ko']
def log(self, message: str, level: str = 'INFO'):
"""Log a message if verbose mode is enabled."""
if self.verbose or level == 'ERROR':
print(f"[{level}] {message}")
def load_json_preserve_order(self, file_path: str) -> Tuple[Dict[str, Any], List[str]]:
"""
Load a JSON file preserving the exact order and formatting.
Returns both the parsed data and the original lines.
"""
with open(file_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
content = ''.join(lines)
# Parse JSON while preserving order
data = json.loads(content, object_pairs_hook=OrderedDict)
return data, lines
def get_all_leaf_keys(self, data: Any, prefix: str = '') -> Dict[str, Any]:
"""
Extract all leaf keys (non-object values) with their full paths.
Returns a dictionary mapping full key paths to their values.
"""
keys = {}
if isinstance(data, (dict, OrderedDict)):
for key, value in data.items():
full_key = f"{prefix}.{key}" if prefix else key
if isinstance(value, (dict, OrderedDict)):
# Recursively get nested keys
keys.update(self.get_all_leaf_keys(value, full_key))
else:
# Leaf node - actual translatable value
keys[full_key] = value
return keys
def merge_json_structures(self, reference_data: Dict[str, Any], target_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Merge the reference JSON structure with existing target translations.
This creates a new structure that matches the reference exactly but preserves
existing translations where available. Keys not in reference are removed.
"""
def merge_recursive(ref_obj, target_obj):
if isinstance(ref_obj, (dict, OrderedDict)):
result = OrderedDict()
# Only include keys that exist in the reference
for key, ref_value in ref_obj.items():
if key in target_obj and isinstance(target_obj[key], type(ref_value)):
# Key exists in target with same type
if isinstance(ref_value, (dict, OrderedDict)):
# Recursively merge nested objects
result[key] = merge_recursive(ref_value, target_obj[key])
else:
# Use existing translation
result[key] = target_obj[key]
else:
# Key missing in target or type mismatch
if isinstance(ref_value, (dict, OrderedDict)):
# Recursively handle nested objects
result[key] = merge_recursive(ref_value, {})
else:
# Create placeholder translation
result[key] = f"[TODO: Translate] {ref_value}"
return result
else:
# For non-dict values, use reference (this shouldn't happen at root level)
return ref_obj
return merge_recursive(reference_data, target_data)
def format_json_like_reference(self, data: Dict[str, Any], reference_lines: List[str]) -> List[str]:
"""
Format the merged JSON data to match the reference file's formatting exactly.
"""
# Use json.dumps with proper formatting to match the reference style
formatted_json = json.dumps(data, indent=4, ensure_ascii=False, separators=(',', ': '))
# Split into lines and ensure consistent line endings
formatted_lines = [line + '\n' for line in formatted_json.split('\n')]
# Make sure the last line doesn't have extra newlines
if formatted_lines and formatted_lines[-1].strip() == '':
formatted_lines = formatted_lines[:-1]
# Ensure the last line ends with just a newline
if formatted_lines and not formatted_lines[-1].endswith('\n'):
formatted_lines[-1] += '\n'
return formatted_lines
def synchronize_locale_simple(self, locale: str, reference_data: Dict[str, Any],
reference_lines: List[str], dry_run: bool = False) -> bool:
"""
Synchronize a locale file using JSON structure merging.
Handles both addition of missing keys and removal of obsolete keys.
"""
locale_file = os.path.join(self.locales_dir, f'{locale}.json')
if not os.path.exists(locale_file):
self.log(f"Locale file {locale_file} does not exist!", 'ERROR')
return False
try:
target_data, _ = self.load_json_preserve_order(locale_file)
except Exception as e:
self.log(f"Error loading {locale_file}: {e}", 'ERROR')
return False
# Get keys to check for differences
ref_keys = self.get_all_leaf_keys(reference_data)
target_keys = self.get_all_leaf_keys(target_data)
missing_keys = set(ref_keys.keys()) - set(target_keys.keys())
obsolete_keys = set(target_keys.keys()) - set(ref_keys.keys())
if not missing_keys and not obsolete_keys:
self.log(f"Locale {locale} is already up to date")
return False
# Report changes
if missing_keys:
self.log(f"Found {len(missing_keys)} missing keys in {locale}:")
for key in sorted(missing_keys):
self.log(f" + {key}")
if obsolete_keys:
self.log(f"Found {len(obsolete_keys)} obsolete keys in {locale}:")
for key in sorted(obsolete_keys):
self.log(f" - {key}")
if dry_run:
total_changes = len(missing_keys) + len(obsolete_keys)
self.log(f"DRY RUN: Would update {locale} with {len(missing_keys)} additions and {len(obsolete_keys)} deletions ({total_changes} total changes)")
return True
# Merge the structures (this will both add missing keys and remove obsolete ones)
try:
merged_data = self.merge_json_structures(reference_data, target_data)
# Format to match reference style
new_lines = self.format_json_like_reference(merged_data, reference_lines)
# Validate that the result is valid JSON
reconstructed_content = ''.join(new_lines)
json.loads(reconstructed_content) # This will raise an exception if invalid
# Write the updated file
with open(locale_file, 'w', encoding='utf-8') as f:
f.writelines(new_lines)
total_changes = len(missing_keys) + len(obsolete_keys)
self.log(f"Successfully updated {locale} with {len(missing_keys)} additions and {len(obsolete_keys)} deletions ({total_changes} total changes)")
return True
except json.JSONDecodeError as e:
self.log(f"Generated invalid JSON for {locale}: {e}", 'ERROR')
return False
except Exception as e:
self.log(f"Error updating {locale_file}: {e}", 'ERROR')
return False
def synchronize_all(self, dry_run: bool = False) -> bool:
"""
Synchronize all locale files with the reference.
Returns True if all operations were successful.
"""
# Load reference file
reference_file = os.path.join(self.locales_dir, f'{self.reference_locale}.json')
if not os.path.exists(reference_file):
self.log(f"Reference file {reference_file} does not exist!", 'ERROR')
return False
try:
reference_data, reference_lines = self.load_json_preserve_order(reference_file)
reference_keys = self.get_all_leaf_keys(reference_data)
except Exception as e:
self.log(f"Error loading reference file: {e}", 'ERROR')
return False
self.log(f"Loaded reference file with {len(reference_keys)} keys")
success = True
changes_made = False
# Synchronize each target locale
for locale in self.target_locales:
try:
if self.synchronize_locale_simple(locale, reference_data, reference_lines, dry_run):
changes_made = True
except Exception as e:
self.log(f"Error synchronizing {locale}: {e}", 'ERROR')
success = False
if changes_made:
self.log("Synchronization completed with changes")
else:
self.log("All locale files are already up to date")
return success
def main():
"""Main entry point for the script."""
parser = argparse.ArgumentParser(
description='Synchronize translation keys from en.json to all other locale files'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be changed without making actual changes'
)
parser.add_argument(
'--verbose', '-v',
action='store_true',
help='Enable verbose output'
)
parser.add_argument(
'--locales-dir',
default=None,
help='Path to locales directory (default: auto-detect from script location)'
)
args = parser.parse_args()
# Determine locales directory
if args.locales_dir:
locales_dir = args.locales_dir
else:
# Auto-detect based on script location
script_dir = os.path.dirname(os.path.abspath(__file__))
locales_dir = os.path.join(os.path.dirname(script_dir), 'locales')
if not os.path.exists(locales_dir):
print(f"ERROR: Locales directory not found: {locales_dir}")
sys.exit(1)
print(f"Translation Key Synchronization")
print(f"Locales directory: {locales_dir}")
print(f"Mode: {'DRY RUN' if args.dry_run else 'LIVE UPDATE'}")
print("-" * 50)
# Create synchronizer and run
synchronizer = TranslationKeySynchronizer(locales_dir, args.verbose)
try:
success = synchronizer.synchronize_all(args.dry_run)
if success:
print("\n✅ Synchronization completed successfully!")
if not args.dry_run:
print("💡 Run 'python test_i18n.py' to verify formatting consistency")
else:
print("\n❌ Synchronization completed with errors!")
sys.exit(1)
except KeyboardInterrupt:
print("\n⚠️ Operation cancelled by user")
sys.exit(1)
except Exception as e:
print(f"\n❌ Unexpected error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -339,6 +339,11 @@ class StandaloneLoraManager(LoraManager):
logger.warning(f"Failed to add static route on initialization for {target_path}: {e}")
continue
# Add static route for locales JSON files
if os.path.exists(config.i18n_path):
app.router.add_static('/locales', config.i18n_path)
logger.info(f"Added static route for locales: /locales -> {config.i18n_path}")
# Add static route for plugin assets
app.router.add_static('/loras_static', config.static_path)

View File

@@ -46,7 +46,7 @@ html, body {
/* Composed Colors */
--lora-accent: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
--lora-surface: oklch(100% 0 0 / 0.98);
--lora-surface: oklch(97% 0 0 / 0.95);
--lora-border: oklch(90% 0.02 256 / 0.15);
--lora-text: oklch(95% 0.02 256);
--lora-error: oklch(75% 0.32 29);

View File

@@ -1,81 +1,3 @@
/* Bulk Operations Styles */
.bulk-operations-panel {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateY(100px) translateX(-50%);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
padding: 12px 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: var(--z-overlay);
display: flex;
flex-direction: column;
min-width: 420px;
max-width: 900px;
width: auto;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
opacity: 0;
}
.bulk-operations-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
gap: 20px; /* Increase space between count and buttons */
}
#selectedCount {
font-weight: 500;
background: var(--bg-color);
padding: 6px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
min-width: 80px;
text-align: center;
}
.bulk-operations-actions {
display: flex;
gap: 8px;
}
.bulk-operations-actions button {
padding: 6px 12px;
border-radius: var(--border-radius-xs);
background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
cursor: pointer;
font-size: 14px;
white-space: nowrap;
min-height: 36px;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
}
.bulk-operations-actions button:hover {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
/* Danger button style - updated to use proper theme variables */
.bulk-operations-actions button.danger-btn {
background: oklch(70% 0.2 29); /* Light red background that works in both themes */
color: oklch(98% 0.01 0); /* Almost white text for good contrast */
border-color: var(--lora-error);
}
.bulk-operations-actions button.danger-btn:hover {
background: var(--lora-error);
color: oklch(100% 0 0); /* Pure white text on hover for maximum contrast */
}
/* Style for selected cards */
.model-card.selected {
box-shadow: 0 0 0 2px var(--lora-accent);
@@ -99,203 +21,61 @@
z-index: 1;
}
/* Update bulk operations button to match others when active */
#bulkOperationsBtn.active {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
@media (max-width: 768px) {
.bulk-operations-panel {
width: calc(100% - 40px);
min-width: unset;
max-width: unset;
left: 20px;
transform: none;
border-radius: var(--border-radius-sm);
}
.bulk-operations-actions {
flex-wrap: wrap;
}
}
.bulk-operations-panel.visible {
transform: translateY(0) translateX(-50%);
opacity: 1;
}
/* Thumbnail Strip Styles */
.selected-thumbnails-strip {
/* Marquee selection styles */
.marquee-selection {
position: fixed;
bottom: 80px; /* Position above the bulk operations panel */
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
z-index: calc(var(--z-overlay) - 1); /* Just below the bulk panel z-index */
padding: 16px;
max-width: 80%;
width: auto;
transition: all 0.3s ease;
opacity: 0;
overflow: hidden;
border: 2px dashed var(--lora-accent, #007bff);
background: rgba(0, 123, 255, 0.1);
pointer-events: none;
z-index: 9999;
border-radius: 2px;
}
.selected-thumbnails-strip.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
/* Visual feedback when marquee selecting */
.marquee-selecting {
cursor: crosshair;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.thumbnails-container {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 8px; /* Space for scrollbar */
/* Prevent text selection during marquee */
.marquee-selecting * {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Remove bulk base model modal specific styles - now using shared components */
/* Use shared metadata editing styles instead */
/* Override for bulk base model select to ensure proper width */
.bulk-base-model-select {
width: 100%;
max-width: 100%;
align-items: flex-start;
}
.selected-thumbnail {
position: relative;
width: 80px;
min-width: 80px; /* Prevent shrinking */
padding: 6px 10px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
overflow: hidden;
cursor: pointer;
background: var(--bg-color);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.selected-thumbnail:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.selected-thumbnail img,
.selected-thumbnail video {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
display: block;
}
.thumbnail-name {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.6);
color: white;
font-size: 10px;
padding: 3px 5px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.thumbnail-remove {
position: absolute;
top: 3px;
right: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
color: white;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 10px;
opacity: 0.7;
transition: opacity 0.2s ease, background-color 0.2s ease;
}
.thumbnail-remove:hover {
opacity: 1;
background: var(--lora-error);
}
.strip-close-btn {
position: absolute;
top: 5px;
right: 5px;
width: 20px;
height: 20px;
background: none;
border: none;
background-color: var(--lora-surface);
color: var(--text-color);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: opacity 0.2s ease;
font-size: 0.95em;
height: 32px;
}
.strip-close-btn:hover {
opacity: 1;
.bulk-base-model-select:focus {
border-color: var(--lora-accent);
outline: none;
}
/* Style the selectedCount to indicate it's clickable */
.selectable-count {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
transition: background-color 0.2s ease;
/* Dark theme support for bulk base model select */
[data-theme="dark"] .bulk-base-model-select {
background-color: rgba(30, 30, 30, 0.9);
color: var(--text-color);
}
.selectable-count:hover {
background: var(--lora-border);
}
.dropdown-caret {
font-size: 12px;
visibility: hidden; /* Will be shown via JS when items are selected */
}
/* Scrollbar styling for the thumbnails container */
.thumbnails-container::-webkit-scrollbar {
height: 6px;
}
.thumbnails-container::-webkit-scrollbar-track {
background: var(--bg-color);
border-radius: 3px;
}
.thumbnails-container::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.thumbnails-container::-webkit-scrollbar-thumb:hover {
background: var(--lora-accent);
}
/* Mobile optimizations */
@media (max-width: 768px) {
.selected-thumbnails-strip {
width: calc(100% - 40px);
max-width: none;
left: 20px;
transform: translateY(20px);
border-radius: var(--border-radius-sm);
}
.selected-thumbnails-strip.visible {
transform: translateY(0);
}
.selected-thumbnail {
width: 70px;
min-width: 70px;
}
[data-theme="dark"] .bulk-base-model-select option {
background-color: #2d2d2d;
color: var(--text-color);
}

View File

@@ -41,7 +41,7 @@
}
/* Responsive adjustments for 1440p screens (2K) */
@media (min-width: 2000px) {
@media (min-width: 2150px) {
.card-grid {
max-width: 1800px; /* Increased for 2K screens */
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
@@ -525,7 +525,7 @@
}
/* For larger screens, allow more space for the cards */
@media (min-width: 2000px) {
@media (min-width: 2150px) {
.card-grid.virtual-scroll {
max-width: 1800px;
}

View File

@@ -27,7 +27,7 @@
}
/* Responsive container for larger screens - match container in layout.css */
@media (min-width: 2000px) {
@media (min-width: 2150px) {
.duplicates-banner .banner-content {
max-width: 1800px;
}
@@ -130,7 +130,7 @@
}
/* Add responsive container adjustments for duplicate groups - match container in banner */
@media (min-width: 2000px) {
@media (min-width: 2150px) {
.duplicate-group {
max-width: 1800px;
}

View File

@@ -6,7 +6,7 @@
z-index: var(--z-header);
height: 48px; /* Reduced height */
width: 100%;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
box-shadow: 0 2px 4px rgba(0,0,0,0.1); /* Slightly stronger shadow */
}
.header-container {
@@ -20,7 +20,7 @@
}
/* Responsive header container for larger screens */
@media (min-width: 2000px) {
@media (min-width: 2150px) {
.header-container {
max-width: 1800px;
}

View File

@@ -40,10 +40,10 @@
border-radius: var(--border-radius-xs);
padding: 8px;
position: absolute;
z-index: 9999; /* 确保在卡片上方显示 */
left: 120%; /* 将tooltip显示在图标右侧 */
top: 50%; /* 垂直居中 */
transform: translateY(-50%); /* 垂直居中 */
z-index: 9999; /* Ensure tooltip appears above cards */
left: 120%; /* Position tooltip to the right of the icon */
top: 50%; /* Vertically center */
transform: translateY(-15%); /* Vertically center */
opacity: 0;
transition: opacity 0.3s;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
@@ -55,12 +55,12 @@
.tooltip .tooltiptext::after {
content: "";
position: absolute;
top: 50%; /* 箭头垂直居中 */
right: 100%; /* 箭头在左侧 */
top: 50%; /* Vertically center arrow */
right: 100%; /* Arrow on the left side */
margin-top: -5px;
border-width: 5px;
border-style: solid;
border-color: transparent var(--lora-border) transparent transparent; /* 箭头指向左侧 */
border-color: transparent var(--lora-border) transparent transparent; /* Arrow points left */
}
.tooltip:hover .tooltiptext {

View File

@@ -176,11 +176,6 @@
background: linear-gradient(45deg, #4a90e2, #357abd);
}
/* Remove old node-color-indicator styles */
.node-color-indicator {
display: none;
}
.send-all-item {
border-top: 1px solid var(--border-color);
font-weight: 500;
@@ -217,4 +212,24 @@
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}
/* Bulk Context Menu Header */
.bulk-context-header {
padding: 10px 12px;
background: var(--card-bg); /* Use card background for subtlety */
color: var(--text-color); /* Use standard text color */
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
font-size: 14px;
border-radius: var(--border-radius-xs) var(--border-radius-xs) 0 0;
border-bottom: 1px solid var(--border-color); /* Add subtle separator */
}
.bulk-context-header i {
width: 16px;
text-align: center;
color: var(--lora-accent); /* Accent only the icon for a hint of color */
}

View File

@@ -37,6 +37,10 @@ body.modal-open {
overflow-x: hidden; /* 防止水平滚动条 */
}
.modal-content-large {
min-height: 480px;
}
/* 当 modal 打开时锁定 body */
body.modal-open {
overflow: hidden !important; /* 覆盖 base.css 中的 scroll */

View File

@@ -445,69 +445,6 @@
border-color: var(--lora-accent);
}
/* Switch styles */
.search-option-switch {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
}
.switch {
position: relative;
display: inline-block;
width: 46px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
}
input:checked + .slider {
background-color: var(--lora-accent);
}
input:focus + .slider {
box-shadow: 0 0 1px var(--lora-accent);
}
input:checked + .slider:before {
transform: translateX(22px);
}
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.search-options-panel,

View File

@@ -80,6 +80,7 @@
align-items: flex-start;
margin-bottom: var(--space-2);
width: 100%;
min-height: 30px; /* Ensure some height even if empty to prevent layout shifts */
}
/* Individual Item */
@@ -153,17 +154,42 @@
}
.metadata-save-btn,
.save-tags-btn {
.save-tags-btn,
.append-tags-btn,
.replace-tags-btn {
background: var(--lora-accent) !important;
color: white !important;
border-color: var(--lora-accent) !important;
}
.metadata-save-btn:hover,
.save-tags-btn:hover {
.save-tags-btn:hover,
.append-tags-btn:hover,
.replace-tags-btn:hover {
opacity: 0.9;
}
/* Specific styling for bulk tag action buttons */
.bulk-append-tags-btn {
background: var(--lora-accent) !important;
color: white !important;
border-color: var(--lora-accent) !important;
}
.bulk-replace-tags-btn {
background: var(--lora-warning, #f59e0b) !important;
color: white !important;
border-color: var(--lora-warning, #f59e0b) !important;
}
.bulk-append-tags-btn:hover {
opacity: 0.9;
}
.bulk-replace-tags-btn:hover {
background: var(--lora-warning-dark, #d97706) !important;
}
/* Add Form */
.metadata-add-form {
display: flex;

View File

@@ -0,0 +1,554 @@
.folder-sidebar {
position: fixed;
top: 68px; /* Below header */
left: 0px;
width: 230px;
height: calc(100vh - 88px);
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
z-index: var(--z-overlay);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
backdrop-filter: blur(8px);
/* Default state: hidden off-screen */
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
/* Visible state */
.folder-sidebar.visible {
transform: translateX(0);
opacity: 1;
pointer-events: all;
}
/* Auto-hide states */
.folder-sidebar.auto-hide {
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
.folder-sidebar.auto-hide.hover-active {
transform: translateX(0);
opacity: 1;
pointer-events: all;
}
.folder-sidebar.collapsed {
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
/* Hover detection area for auto-hide */
.sidebar-hover-area {
position: fixed;
top: 68px;
left: 0;
width: 20px;
height: calc(100vh - 88px);
z-index: calc(var(--z-overlay) - 1);
background: transparent;
pointer-events: all;
}
.sidebar-hover-area.disabled {
pointer-events: none;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--bg-color);
color: var(--text-color);
font-weight: 500;
font-size: 0.9em;
flex-shrink: 0;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.2s ease;
}
.sidebar-header:hover {
background: var(--lora-surface);
}
.sidebar-header.root-selected {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
color: var(--lora-accent);
}
.sidebar-header h3 {
margin: 0;
font-size: 0.9em;
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
flex: 1;
pointer-events: none;
}
.sidebar-header-actions {
display: flex;
align-items: center;
gap: 4px;
}
.sidebar-action-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
border-radius: 4px;
opacity: 0.6;
transition: all 0.2s ease;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar-action-btn:hover {
opacity: 1;
background: var(--lora-surface);
color: var(--text-color);
}
.sidebar-action-btn.active {
opacity: 1;
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15);
color: var(--lora-accent);
}
.sidebar-action-btn.disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Remove old close button styles */
.sidebar-toggle-close {
display: none;
}
.sidebar-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.sidebar-tree-container {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
/* Sidebar Tree Node Styles */
.sidebar-tree-node {
position: relative;
user-select: none;
}
.sidebar-tree-node-content {
display: flex;
align-items: center;
padding: 8px 16px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.85em;
border-left: 3px solid transparent;
color: var(--text-color);
}
.sidebar-tree-node-content:hover {
background: var(--lora-surface);
color: var(--text-color);
}
.sidebar-tree-node-content.selected {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
color: var(--lora-accent);
border-left-color: var(--lora-accent);
font-weight: 500;
}
.sidebar-tree-expand-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 4px;
transition: transform 0.2s ease;
opacity: 0.6;
}
.sidebar-tree-expand-icon.expanded {
transform: rotate(90deg);
}
.sidebar-tree-expand-icon i {
font-size: 10px;
}
.sidebar-tree-folder-icon {
margin-right: 8px;
color: var(--text-muted);
opacity: 0.7;
}
.sidebar-tree-node-content.selected .sidebar-tree-folder-icon {
color: var(--lora-accent);
opacity: 1;
}
.sidebar-tree-node-content:hover .sidebar-tree-folder-icon {
color: var(--text-color);
opacity: 0.9;
}
.sidebar-tree-folder-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-tree-children {
overflow: hidden;
max-height: 0;
transition: max-height 0.3s ease;
}
.sidebar-tree-children.expanded {
max-height: 1000px;
}
.sidebar-tree-children .sidebar-tree-node-content {
padding-left: 32px;
}
.sidebar-tree-children .sidebar-tree-children .sidebar-tree-node-content {
padding-left: 48px;
}
.sidebar-tree-children .sidebar-tree-children .sidebar-tree-children .sidebar-tree-node-content {
padding-left: 64px;
}
/* Enhanced Sidebar Breadcrumb Styles */
.sidebar-breadcrumb-container {
margin-top: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
}
.sidebar-breadcrumb-nav {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
font-size: 0.85em;
padding: 0 8px;
}
.sidebar-breadcrumb-item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: all 0.2s ease;
color: var(--text-muted);
position: relative;
}
.sidebar-breadcrumb-item:hover {
background: var(--lora-surface);
color: var(--text-color);
}
.sidebar-breadcrumb-item.active {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
color: var(--lora-accent);
font-weight: 500;
}
.sidebar-breadcrumb-separator {
color: var(--text-muted);
opacity: 0.6;
margin: 0 2px;
}
/* New Breadcrumb Dropdown Styles */
.breadcrumb-dropdown {
position: relative;
display: inline-flex;
align-items: center;
}
.breadcrumb-dropdown-indicator {
margin-left: 6px;
color: inherit;
opacity: 0.6;
transition: all 0.2s ease;
pointer-events: none;
font-size: 0.9em;
}
.sidebar-breadcrumb-item:hover .breadcrumb-dropdown-indicator {
opacity: 0.9;
}
.sidebar-breadcrumb-item.placeholder {
color: var(--text-muted);
font-style: italic;
}
.sidebar-breadcrumb-item.placeholder:hover {
background: var(--lora-surface);
color: var(--text-color);
}
.breadcrumb-dropdown.open .breadcrumb-dropdown-indicator {
transform: rotate(180deg);
opacity: 1;
}
.breadcrumb-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
min-width: 160px;
max-width: 240px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
box-shadow: 0 3px 8px rgba(0,0,0,0.15);
z-index: calc(var(--z-overlay) + 20);
overflow-y: auto;
max-height: 450px;
display: none;
margin-top: 4px;
}
.breadcrumb-dropdown.open .breadcrumb-dropdown-menu {
display: block;
}
.breadcrumb-dropdown-item {
padding: 6px 12px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: all 0.2s ease;
}
.breadcrumb-dropdown-item:hover {
background: var(--lora-surface);
}
.breadcrumb-dropdown-item.active {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
color: var(--lora-accent);
font-weight: 500;
}
/* Folder List Mode Styles */
.sidebar-folder-item {
position: relative;
user-select: none;
}
.sidebar-node-content {
display: flex;
align-items: center;
padding: 8px 16px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.85em;
border-left: 3px solid transparent;
color: var(--text-color);
}
.sidebar-node-content:hover {
background: var(--lora-surface);
color: var(--text-color);
}
.sidebar-folder-item.selected .sidebar-node-content {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
color: var(--lora-accent);
border-left-color: var(--lora-accent);
font-weight: 500;
}
.sidebar-folder-icon {
margin-right: 8px;
color: var(--text-muted);
opacity: 0.7;
width: 16px;
text-align: center;
}
.sidebar-folder-item.selected .sidebar-folder-icon {
color: var(--lora-accent);
opacity: 1;
}
.sidebar-node-content:hover .sidebar-folder-icon {
color: var(--text-color);
opacity: 0.9;
}
.sidebar-folder-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Responsive Design */
@media (min-width: 2150px) {
.folder-sidebar {
width: 280px;
left: 0px;
}
}
@media (min-width: 3000px) {
.folder-sidebar {
width: 320px;
left: 0px;
}
}
@media (max-width: 1400px) {
.folder-sidebar {
width: 260px;
left: 0px;
}
}
/* Empty State */
.sidebar-tree-placeholder {
padding: 24px 16px;
text-align: center;
color: var(--text-muted);
opacity: 0.7;
}
.sidebar-tree-placeholder i {
font-size: 2em;
opacity: 0.5;
margin-bottom: 8px;
display: block;
}
/* Smooth transitions for tree nodes */
.sidebar-tree-node {
overflow: hidden;
}
.sidebar-tree-children {
transition: max-height 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar-tree-expand-icon {
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Visual separator for nested levels */
.sidebar-tree-children .sidebar-tree-node-content {
position: relative;
}
.sidebar-tree-children .sidebar-tree-node-content::before {
content: '';
position: absolute;
left: 8px;
top: 0;
bottom: 0;
width: 1px;
background: var(--border-color);
opacity: 0.3;
}
/* Responsive Design */
@media (max-width: 1024px) {
.folder-sidebar {
top: 68px;
left: 0px;
width: calc(100vw - 32px);
max-width: 320px;
height: calc(100vh - 88px);
z-index: calc(var(--z-overlay) + 10);
}
.folder-sidebar.collapsed {
transform: translateX(-100%);
}
/* Mobile overlay */
.folder-sidebar:not(.collapsed)::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: -1;
backdrop-filter: blur(2px);
}
}
@media (max-width: 768px) {
.folder-sidebar {
width: calc(100vw - 32px);
max-width: 280px;
left: 0px;
}
.sidebar-breadcrumb-nav {
font-size: 0.8em;
}
.sidebar-breadcrumb-item {
padding: 3px 6px;
}
}
/* Hide scrollbar but keep functionality */
.sidebar-tree-container::-webkit-scrollbar {
width: 6px;
}
.sidebar-tree-container::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-tree-container::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.sidebar-tree-container::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}

View File

@@ -7,6 +7,7 @@
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-2);
margin-top: var(--space-2);
margin-bottom: var(--space-3);
}

View File

@@ -9,14 +9,24 @@
.container {
max-width: 1400px;
margin: 20px auto;
margin: 0 auto;
padding: 0 15px;
position: relative;
z-index: var(--z-base);
}
/* Sticky controls container */
.controls {
position: sticky;
top: -54px;
z-index: calc(var(--z-header) - 1);
background: var(--bg-color);
padding: var(--space-2) 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
/* Responsive container for larger screens */
@media (min-width: 2000px) {
@media (min-width: 2150px) {
.container {
max-width: 1800px;
}
@@ -28,13 +38,6 @@
}
}
.controls {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: var(--space-2);
}
.controls-right {
display: flex;
align-items: center;
@@ -225,63 +228,6 @@
display: none !important;
}
.folder-tags-container {
position: relative;
width: 100%;
margin-bottom: 8px; /* Add margin to ensure space for the button */
}
.folder-tags {
display: flex;
gap: 4px;
padding: 2px 0;
flex-wrap: wrap;
transition: max-height 0.3s ease, opacity 0.2s ease;
max-height: 150px; /* Limit height to prevent overflow */
opacity: 1;
overflow-y: auto; /* Enable vertical scrolling */
margin-bottom: 5px; /* Add margin below the tags */
}
.folder-tags.collapsed {
max-height: 0;
opacity: 0;
margin: 0;
padding-bottom: 0;
overflow: hidden;
}
.toggle-folders-container {
margin-left: auto;
}
/* Toggle Folders Button */
.toggle-folders-btn {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.toggle-folders-btn:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1);
}
.toggle-folders-btn i {
transition: transform 0.3s ease;
}
/* Icon-only button style */
.icon-only {
min-width: unset !important;
@@ -290,55 +236,6 @@
height: 32px !important;
}
/* Rotate icon when folders are collapsed */
.folder-tags.collapsed ~ .actions .toggle-folders-btn i {
transform: rotate(180deg);
}
/* Add custom scrollbar for better visibility */
.folder-tags::-webkit-scrollbar {
width: 6px;
}
.folder-tags::-webkit-scrollbar-track {
background: var(--card-bg);
border-radius: 3px;
}
.folder-tags::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.folder-tags::-webkit-scrollbar-thumb:hover {
background: var(--lora-accent);
}
.tag {
cursor: pointer;
padding: 2px 8px;
margin: 2px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
display: inline-block;
line-height: 1.2;
font-size: 14px;
background-color: var(--card-bg);
transition: all 0.2s ease;
}
.tag:hover {
border-color: var(--lora-accent);
background-color: oklch(var(--lora-accent) / 0.1);
transform: translateY(-1px);
}
.tag.active {
background-color: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
/* Back to Top Button */
.back-to-top {
position: fixed;
@@ -376,10 +273,8 @@
}
/* Prevent text selection in control and header areas */
.tag,
.control-group button,
.control-group select,
.toggle-folders-btn,
.bulk-operations-panel,
.app-header,
.header-branding,
@@ -387,8 +282,7 @@
.main-nav,
.nav-item,
.header-actions button,
.header-controls,
.toggle-folders-container button {
.header-controls {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
@@ -472,18 +366,6 @@
justify-content: flex-end;
margin-top: 8px;
}
.toggle-folders-container {
margin-left: 0;
}
.folder-tags-container {
order: -1;
}
.toggle-folders-btn:hover {
transform: none; /* Disable hover effects on mobile */
}
.control-group button:hover {
transform: none; /* Disable hover effects on mobile */
@@ -493,10 +375,6 @@
transform: none; /* Disable hover effects on mobile */
}
.tag:hover {
transform: none; /* Disable hover effects on mobile */
}
.back-to-top {
bottom: 60px; /* Give some extra space from bottom on mobile */
}
@@ -505,4 +383,9 @@
left: auto;
right: 0; /* Align to right on mobile */
}
/* Adjust controls padding on mobile */
.controls {
padding: 10px 0;
}
}

252
static/css/onboarding.css Normal file
View File

@@ -0,0 +1,252 @@
/* Onboarding Tutorial Styles */
.onboarding-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: var(--z-overlay);
display: none;
/* Use mask to create cutout for highlighted element */
mask-composite: subtract;
-webkit-mask-composite: subtract;
}
.onboarding-overlay.active {
display: block;
}
.onboarding-spotlight {
position: absolute;
background: transparent;
border: 3px solid var(--lora-accent);
border-radius: var(--border-radius-base);
z-index: calc(var(--z-overlay) + 1);
pointer-events: none;
transition: all 0.3s ease;
/* Add glow effect */
box-shadow:
0 0 0 2px rgba(24, 144, 255, 0.3),
0 0 20px rgba(24, 144, 255, 0.2),
inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
/* Target element highlighting */
.onboarding-target-highlight {
position: relative;
z-index: calc(var(--z-overlay) + 2) !important;
pointer-events: auto !important;
}
/* Ensure highlighted elements are interactive */
.onboarding-target-highlight * {
pointer-events: auto !important;
}
.onboarding-popup {
position: absolute;
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-base);
padding: var(--space-3);
min-width: 320px;
max-width: 400px;
z-index: calc(var(--z-overlay) + 3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
}
.onboarding-popup h3 {
margin: 0 0 var(--space-2) 0;
color: var(--lora-accent);
font-size: 1.2em;
font-weight: 600;
}
.onboarding-popup p {
margin: 0 0 var(--space-3) 0;
color: var(--text-color);
line-height: 1.5;
}
.onboarding-controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-2);
}
.onboarding-progress {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: 0.85em;
color: var(--text-muted);
}
.onboarding-actions {
display: flex;
gap: var(--space-2);
}
.onboarding-btn {
padding: var(--space-1) var(--space-2);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
background: var(--card-bg);
color: var(--text-color);
cursor: pointer;
font-size: 0.9em;
transition: all 0.2s ease;
}
.onboarding-btn:hover {
background: var(--lora-accent);
color: var(--lora-text);
border-color: var(--lora-accent);
}
.onboarding-btn.primary {
background: var(--lora-accent);
color: var(--lora-text);
border-color: var(--lora-accent);
}
.onboarding-btn.primary:hover {
opacity: 0.9;
}
/* Language Selection Modal */
.language-selection-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: calc(var(--z-overlay) + 10);
}
.language-selection-content {
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-base);
padding: var(--space-3);
min-width: 510px;
text-align: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
backdrop-filter: blur(10px);
}
.language-selection-content h2 {
margin: 0 0 var(--space-2) 0;
color: var(--lora-accent);
font-size: 1.5em;
}
.language-selection-content p {
margin: 0 0 var(--space-3) 0;
color: var(--text-color);
line-height: 1.5;
}
.language-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.language-option {
padding: var(--space-2);
border: 2px solid var(--lora-border);
border-radius: var(--border-radius-sm);
background: var(--card-bg);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
}
.language-option:hover {
border-color: var(--lora-accent);
background: var(--lora-surface);
}
.language-option.selected {
border-color: var(--lora-accent);
background: var(--lora-accent);
color: var(--lora-text);
}
.language-flag {
font-size: 1.5em;
}
.language-name {
font-size: 0.9em;
font-weight: 500;
}
.language-actions {
display: flex;
gap: var(--space-2);
justify-content: center;
}
/* Shortcut Key Highlighting */
.onboarding-shortcut {
display: inline-block;
background: var(--shortcut-bg);
border: 1px solid var(--shortcut-border);
border-radius: var(--border-radius-xs);
padding: 2px 6px;
font-size: 0.8em;
font-weight: 600;
color: var(--shortcut-text);
margin: 0 2px;
}
/* Animation for highlighting elements */
.onboarding-highlight {
animation: onboarding-pulse 2s infinite;
}
@keyframes onboarding-pulse {
0%, 100% {
box-shadow:
0 0 0 2px rgba(24, 144, 255, 0.4),
0 0 20px rgba(24, 144, 255, 0.3),
inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
50% {
box-shadow:
0 0 0 4px rgba(24, 144, 255, 0.6),
0 0 30px rgba(24, 144, 255, 0.4),
inset 0 0 0 1px rgba(255, 255, 255, 0.2);
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.onboarding-popup {
min-width: 280px;
max-width: calc(100vw - 40px);
padding: var(--space-2);
}
.language-grid {
grid-template-columns: repeat(2, 1fr);
}
.language-selection-content {
min-width: calc(100vw - 40px);
max-width: 400px;
}
}

View File

@@ -34,10 +34,10 @@
@import 'components/filter-indicator.css';
@import 'components/initialization.css';
@import 'components/progress-panel.css';
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */
@import 'components/duplicates.css'; /* Add duplicates component */
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
@import 'components/statistics.css'; /* Add statistics component */
@import 'components/sidebar.css'; /* Add sidebar component */
.initialization-notice {
display: flex;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -63,6 +63,9 @@ export function getApiEndpoints(modelType) {
// Bulk operations
bulkDelete: `/api/${modelType}/bulk-delete`,
// Tag operations
addTags: `/api/${modelType}/add-tags`,
// Move operations (now common for all model types that support move)
moveModel: `/api/${modelType}/move_model`,
@@ -88,6 +91,8 @@ export function getApiEndpoints(modelType) {
duplicates: `/api/${modelType}/find-duplicates`,
conflicts: `/api/${modelType}/find-filename-conflicts`,
verify: `/api/${modelType}/verify-duplicates`,
metadata: `/api/${modelType}/metadata`,
modelDescription: `/api/${modelType}/model-description`,
// Model-specific endpoints (will be merged with specific configs)
specific: {}
@@ -104,7 +109,7 @@ export const MODEL_SPECIFIC_ENDPOINTS = {
triggerWords: `/api/${MODEL_TYPES.LORA}/get-trigger-words`,
previewUrl: `/api/${MODEL_TYPES.LORA}/preview-url`,
civitaiUrl: `/api/${MODEL_TYPES.LORA}/civitai-url`,
modelDescription: `/api/${MODEL_TYPES.LORA}/model-description`,
metadata: `/api/${MODEL_TYPES.LORA}/metadata`,
getTriggerWordsPost: `/api/${MODEL_TYPES.LORA}/get_trigger_words`,
civitaiModelByVersion: `/api/${MODEL_TYPES.LORA}/civitai/model/version`,
civitaiModelByHash: `/api/${MODEL_TYPES.LORA}/civitai/model/hash`,
@@ -113,8 +118,10 @@ export const MODEL_SPECIFIC_ENDPOINTS = {
info: `/api/${MODEL_TYPES.CHECKPOINT}/info`,
checkpoints_roots: `/api/${MODEL_TYPES.CHECKPOINT}/checkpoints_roots`,
unet_roots: `/api/${MODEL_TYPES.CHECKPOINT}/unet_roots`,
metadata: `/api/${MODEL_TYPES.CHECKPOINT}/metadata`,
},
[MODEL_TYPES.EMBEDDING]: {
metadata: `/api/${MODEL_TYPES.EMBEDDING}/metadata`,
}
};

View File

@@ -1,5 +1,6 @@
import { state, getCurrentPageState } from '../state/index.js';
import { showToast, updateFolderTags } from '../utils/uiHelpers.js';
import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { getStorageItem, getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
import {
getCompleteApiConfig,
@@ -9,6 +10,7 @@ import {
WS_ENDPOINTS
} from './apiConfig.js';
import { resetAndReload } from './modelApiFactory.js';
import { sidebarManager } from '../components/SidebarManager.js';
/**
* Abstract base class for all model API clients
@@ -75,7 +77,7 @@ export class BaseModelApiClient {
} catch (error) {
console.error(`Error fetching ${this.apiConfig.config.displayName}s:`, error);
showToast(`Failed to fetch ${this.apiConfig.config.displayName}s: ${error.message}`, 'error');
showToast('toast.api.fetchFailed', { type: this.apiConfig.config.displayName, message: error.message }, 'error');
throw error;
}
}
@@ -103,21 +105,13 @@ export class BaseModelApiClient {
pageState.currentPage = pageState.currentPage + 1;
if (updateFolders) {
const response = await fetch(this.apiConfig.endpoints.folders);
if (response.ok) {
const data = await response.json();
updateFolderTags(data.folders);
} else {
const errorData = await response.json().catch(() => ({}));
const errorMsg = errorData && errorData.error ? errorData.error : response.statusText;
console.error(`Error getting folders: ${errorMsg}`);
}
sidebarManager.refresh();
}
return result;
} catch (error) {
console.error(`Error reloading ${this.apiConfig.config.displayName}s:`, error);
showToast(`Failed to reload ${this.apiConfig.config.displayName}s: ${error.message}`, 'error');
showToast('toast.api.reloadFailed', { type: this.apiConfig.config.displayName, message: error.message }, 'error');
throw error;
} finally {
pageState.isLoading = false;
@@ -145,14 +139,14 @@ export class BaseModelApiClient {
if (state.virtualScroller) {
state.virtualScroller.removeItemByFilePath(filePath);
}
showToast(`${this.apiConfig.config.displayName} deleted successfully`, 'success');
showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success');
return true;
} else {
throw new Error(data.error || `Failed to delete ${this.apiConfig.config.singularName}`);
}
} catch (error) {
console.error(`Error deleting ${this.apiConfig.config.singularName}:`, error);
showToast(`Failed to delete ${this.apiConfig.config.singularName}: ${error.message}`, 'error');
showToast('toast.api.deleteFailed', { type: this.apiConfig.config.singularName, message: error.message }, 'error');
return false;
} finally {
state.loadingManager.hide();
@@ -179,14 +173,14 @@ export class BaseModelApiClient {
if (state.virtualScroller) {
state.virtualScroller.removeItemByFilePath(filePath);
}
showToast(`${this.apiConfig.config.displayName} excluded successfully`, 'success');
showToast('toast.api.excludeSuccess', { type: this.apiConfig.config.displayName }, 'success');
return true;
} else {
throw new Error(data.error || `Failed to exclude ${this.apiConfig.config.singularName}`);
}
} catch (error) {
console.error(`Error excluding ${this.apiConfig.config.singularName}:`, error);
showToast(`Failed to exclude ${this.apiConfig.config.singularName}: ${error.message}`, 'error');
showToast('toast.api.excludeFailed', { type: this.apiConfig.config.singularName, message: error.message }, 'error');
return false;
} finally {
state.loadingManager.hide();
@@ -215,9 +209,9 @@ export class BaseModelApiClient {
preview_url: result.new_preview_path
});
showToast('File name updated successfully', 'success');
showToast('toast.api.fileNameUpdated', {}, 'success');
} else {
showToast('Failed to rename file: ' + (result.error || 'Unknown error'), 'error');
showToast('toast.api.fileRenameFailed', { error: result.error || 'Unknown error' }, 'error');
}
return result;
@@ -279,10 +273,10 @@ export class BaseModelApiClient {
};
state.virtualScroller.updateSingleItem(filePath, updateData);
showToast('Preview updated successfully', 'success');
showToast('toast.api.previewUpdated', {}, 'success');
} catch (error) {
console.error('Error uploading preview:', error);
showToast('Failed to upload preview image', 'error');
showToast('toast.api.previewUploadFailed', {}, 'error');
} finally {
state.loadingManager.hide();
}
@@ -312,6 +306,34 @@ export class BaseModelApiClient {
}
}
async addTags(filePath, data) {
try {
const response = await fetch(this.apiConfig.endpoints.addTags, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_path: filePath,
...data
})
});
if (!response.ok) {
throw new Error('Failed to add tags');
}
const result = await response.json();
if (result.success && result.tags) {
state.virtualScroller.updateSingleItem(filePath, { tags: result.tags });
}
return result;
} catch (error) {
console.error('Error adding tags:', error);
throw error;
}
}
async refreshModels(fullRebuild = false) {
try {
state.loadingManager.showSimpleLoading(
@@ -329,10 +351,10 @@ export class BaseModelApiClient {
resetAndReload(true);
showToast(`${fullRebuild ? 'Full rebuild' : 'Refresh'} complete`, 'success');
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
} catch (error) {
console.error('Refresh failed:', error);
showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${this.apiConfig.config.displayName}s`, 'error');
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: this.apiConfig.config.displayName }, 'error');
} finally {
state.loadingManager.hide();
state.loadingManager.restoreProgressBar();
@@ -360,14 +382,14 @@ export class BaseModelApiClient {
state.virtualScroller.updateSingleItem(filePath, data.metadata);
}
showToast('Metadata refreshed successfully', 'success');
showToast('toast.api.metadataRefreshed', {}, 'success');
return true;
} else {
throw new Error(data.error || 'Failed to refresh metadata');
}
} catch (error) {
console.error('Error refreshing metadata:', error);
showToast(error.message, 'error');
showToast('toast.api.metadataRefreshFailed', { message: error.message }, 'error');
return false;
} finally {
state.loadingManager.hide();
@@ -419,6 +441,7 @@ export class BaseModelApiClient {
};
});
// Wait for WebSocket connection to establish
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = reject;
@@ -434,13 +457,14 @@ export class BaseModelApiClient {
throw new Error('Failed to fetch metadata');
}
// Wait for the operation to complete via WebSocket
await operationComplete;
resetAndReload(false);
showToast('Metadata update complete', 'success');
showToast('toast.api.metadataUpdateComplete', {}, 'success');
} catch (error) {
console.error('Error fetching metadata:', error);
showToast('Failed to fetch metadata: ' + error.message, 'error');
showToast('toast.api.metadataFetchFailed', { message: error.message }, 'error');
} finally {
if (ws) {
ws.close();
@@ -508,22 +532,22 @@ export class BaseModelApiClient {
let completionMessage;
if (successCount === totalItems) {
completionMessage = `Successfully refreshed all ${successCount} ${this.apiConfig.config.displayName}s`;
showToast(completionMessage, 'success');
completionMessage = translate('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, `Successfully refreshed all ${successCount} ${this.apiConfig.config.displayName}s`);
showToast('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, 'success');
} else if (successCount > 0) {
completionMessage = `Refreshed ${successCount} of ${totalItems} ${this.apiConfig.config.displayName}s`;
showToast(completionMessage, 'warning');
completionMessage = translate('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, `Refreshed ${successCount} of ${totalItems} ${this.apiConfig.config.displayName}s`);
showToast('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, 'warning');
if (failedItems.length > 0) {
const failureMessage = failedItems.length <= 3
? failedItems.map(item => `${item.fileName}: ${item.error}`).join('\n')
: failedItems.slice(0, 3).map(item => `${item.fileName}: ${item.error}`).join('\n') +
`\n(and ${failedItems.length - 3} more)`;
showToast(`Failed refreshes:\n${failureMessage}`, 'warning', 6000);
showToast('toast.api.bulkMetadataFailureDetails', { failures: failureMessage }, 'warning', 6000);
}
} else {
completionMessage = `Failed to refresh metadata for any ${this.apiConfig.config.displayName}s`;
showToast(completionMessage, 'error');
completionMessage = translate('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, `Failed to refresh metadata for any ${this.apiConfig.config.displayName}s`);
showToast('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, 'error');
}
await progressController.complete(completionMessage);
@@ -539,7 +563,7 @@ export class BaseModelApiClient {
} catch (error) {
console.error('Error in bulk metadata refresh:', error);
showToast(`Failed to refresh metadata: ${error.message}`, 'error');
showToast('toast.api.bulkMetadataFailed', { message: error.message }, 'error');
await progressController.complete('Operation failed');
throw error;
}
@@ -669,9 +693,10 @@ export class BaseModelApiClient {
if (pageState.searchOptions.creator !== undefined) {
params.append('search_creator', pageState.searchOptions.creator.toString());
}
params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString());
}
}
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
if (pageState.filters) {
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
@@ -714,11 +739,11 @@ export class BaseModelApiClient {
async moveSingleModel(filePath, targetPath) {
// Only allow move if supported
if (!this.apiConfig.config.supportsMove) {
showToast(`Moving ${this.apiConfig.config.displayName}s is not supported`, 'warning');
showToast('toast.api.moveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
return null;
}
if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) {
showToast(`${this.apiConfig.config.displayName} is already in the selected folder`, 'info');
showToast('toast.api.alreadyInFolder', { type: this.apiConfig.config.displayName }, 'info');
return null;
}
@@ -743,20 +768,23 @@ export class BaseModelApiClient {
}
if (result && result.message) {
showToast(result.message, 'info');
showToast('toast.api.moveInfo', { message: result.message }, 'info');
} else {
showToast(`${this.apiConfig.config.displayName} moved successfully`, 'success');
showToast('toast.api.moveSuccess', { type: this.apiConfig.config.displayName }, 'success');
}
if (result.success) {
return result.new_file_path;
return {
original_file_path: result.original_file_path || filePath,
new_file_path: result.new_file_path
};
}
return null;
}
async moveBulkModels(filePaths, targetPath) {
if (!this.apiConfig.config.supportsMove) {
showToast(`Moving ${this.apiConfig.config.displayName}s is not supported`, 'warning');
showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
return [];
}
const movedPaths = filePaths.filter(path => {
@@ -764,7 +792,7 @@ export class BaseModelApiClient {
});
if (movedPaths.length === 0) {
showToast(`All selected ${this.apiConfig.config.displayName}s are already in the target folder`, 'info');
showToast('toast.api.allAlreadyInFolder', { type: this.apiConfig.config.displayName }, 'info');
return [];
}
@@ -785,33 +813,38 @@ export class BaseModelApiClient {
throw new Error(`Failed to move ${this.apiConfig.config.displayName}s`);
}
let successFilePaths = [];
if (result.success) {
if (result.failure_count > 0) {
showToast(`Moved ${result.success_count} ${this.apiConfig.config.displayName}s, ${result.failure_count} failed`, 'warning');
showToast('toast.api.bulkMovePartial', {
successCount: result.success_count,
type: this.apiConfig.config.displayName,
failureCount: result.failure_count
}, 'warning');
console.log('Move operation results:', result.results);
const failedFiles = result.results
.filter(r => !r.success)
.map(r => {
const fileName = r.path.substring(r.path.lastIndexOf('/') + 1);
const fileName = r.original_file_path.substring(r.original_file_path.lastIndexOf('/') + 1);
return `${fileName}: ${r.message}`;
});
if (failedFiles.length > 0) {
const failureMessage = failedFiles.length <= 3
? failedFiles.join('\n')
: failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`;
showToast(`Failed moves:\n${failureMessage}`, 'warning', 6000);
showToast('toast.api.bulkMoveFailures', { failures: failureMessage }, 'warning', 6000);
}
} else {
showToast(`Successfully moved ${result.success_count} ${this.apiConfig.config.displayName}s`, 'success');
showToast('toast.api.bulkMoveSuccess', {
successCount: result.success_count,
type: this.apiConfig.config.displayName
}, 'success');
}
successFilePaths = result.results
.filter(r => r.success)
.map(r => r.path);
// Return the results array with original_file_path and new_file_path
return result.results || [];
} else {
throw new Error(result.message || `Failed to move ${this.apiConfig.config.displayName}s`);
}
return successFilePaths;
}
async bulkDeleteModels(filePaths) {
@@ -936,12 +969,12 @@ export class BaseModelApiClient {
// Wait for the operation to complete via WebSocket
await operationComplete;
showToast('Successfully downloaded example images!', 'success');
showToast('toast.api.exampleImagesDownloadSuccess', {}, 'success');
return true;
} catch (error) {
console.error('Error downloading example images:', error);
showToast(`Failed to download example images: ${error.message}`, 'error');
showToast('toast.api.exampleImagesDownloadFailed', { message: error.message }, 'error');
throw error;
} finally {
if (ws) {
@@ -953,4 +986,48 @@ export class BaseModelApiClient {
completionMessage: 'Example images download complete'
});
}
async fetchModelMetadata(filePath) {
try {
const params = new URLSearchParams({ file_path: filePath });
const response = await fetch(`${this.apiConfig.endpoints.metadata}?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch ${this.apiConfig.config.singularName} metadata: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
return data.metadata;
} else {
throw new Error(data.error || `No metadata found for ${this.apiConfig.config.singularName}`);
}
} catch (error) {
console.error(`Error fetching ${this.apiConfig.config.singularName} metadata:`, error);
throw error;
}
}
async fetchModelDescription(filePath) {
try {
const params = new URLSearchParams({ file_path: filePath });
const response = await fetch(`${this.apiConfig.endpoints.modelDescription}?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch ${this.apiConfig.config.singularName} description: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
return data.description;
} else {
throw new Error(data.error || `No description found for ${this.apiConfig.config.singularName}`);
}
} catch (error) {
console.error(`Error fetching ${this.apiConfig.config.singularName} description:`, error);
throw error;
}
}
}

View File

@@ -1,5 +1,4 @@
import { BaseModelApiClient } from './baseModelApi.js';
import { showToast } from '../utils/uiHelpers.js';
/**
* Checkpoint-specific API client

View File

@@ -1,5 +1,4 @@
import { BaseModelApiClient } from './baseModelApi.js';
import { showToast } from '../utils/uiHelpers.js';
/**
* Embedding-specific API client

View File

@@ -1,5 +1,4 @@
import { BaseModelApiClient } from './baseModelApi.js';
import { showToast } from '../utils/uiHelpers.js';
import { getSessionItem } from '../utils/storageHelpers.js';
/**

View File

@@ -89,7 +89,7 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
};
} catch (error) {
console.error('Error fetching recipes:', error);
showToast(`Failed to fetch recipes: ${error.message}`, 'error');
showToast('toast.recipes.fetchFailed', { message: error.message }, 'error');
throw error;
}
}
@@ -131,7 +131,7 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
return result;
} catch (error) {
console.error(`Error reloading ${modelType}s:`, error);
showToast(`Failed to reload ${modelType}s: ${error.message}`, 'error');
showToast('toast.recipes.reloadFailed', { modelType: modelType, message: error.message }, 'error');
throw error;
} finally {
pageState.isLoading = false;
@@ -179,7 +179,7 @@ export async function loadMoreWithVirtualScroll(options = {}) {
return result;
} catch (error) {
console.error(`Error loading ${modelType}s:`, error);
showToast(`Failed to load ${modelType}s: ${error.message}`, 'error');
showToast('toast.recipes.loadFailed', { modelType: modelType, message: error.message }, 'error');
throw error;
} finally {
pageState.isLoading = false;
@@ -217,10 +217,10 @@ export async function refreshRecipes() {
// After successful cache rebuild, reload the recipes
await resetAndReload();
showToast('Refresh complete', 'success');
showToast('toast.recipes.refreshComplete', {}, 'success');
} catch (error) {
console.error('Error refreshing recipes:', error);
showToast(error.message || 'Failed to refresh recipes', 'error');
showToast('toast.recipes.refreshFailed', { message: error.message }, 'error');
} finally {
state.loadingManager.hide();
state.loadingManager.restoreProgressBar();
@@ -285,7 +285,7 @@ export async function updateRecipeMetadata(filePath, updates) {
const data = await response.json();
if (!data.success) {
showToast(`Failed to update recipe: ${data.error}`, 'error');
showToast('toast.recipes.updateFailed', { error: data.error }, 'error');
throw new Error(data.error || 'Failed to update recipe');
}
@@ -294,7 +294,7 @@ export async function updateRecipeMetadata(filePath, updates) {
return data;
} catch (error) {
console.error('Error updating recipe:', error);
showToast(`Error updating recipe: ${error.message}`, 'error');
showToast('toast.recipes.updateError', { message: error.message }, 'error');
throw error;
} finally {
state.loadingManager.hide();

View File

@@ -1,7 +1,6 @@
import { appCore } from './core.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { createPageControls } from './components/controls/index.js';
import { CheckpointContextMenu } from './components/ContextMenu/index.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { MODEL_TYPES } from './api/apiConfig.js';
@@ -30,14 +29,7 @@ class CheckpointsPageManager {
}
async initialize() {
// Initialize page-specific components
this.pageControls.restoreFolderFilter();
this.pageControls.initFolderTagsVisibility();
// Initialize context menu
new CheckpointContextMenu();
// Initialize common page features
// Initialize common page features (including context menus)
appCore.initializePageFeatures();
console.log('Checkpoints Manager initialized');
@@ -52,4 +44,4 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize checkpoints page
const checkpointsPage = new CheckpointsPageManager();
await checkpointsPage.initialize();
});
});

View File

@@ -15,17 +15,6 @@ export class BaseContextMenu {
init() {
// Hide menu on regular clicks
document.addEventListener('click', () => this.hideMenu());
// Show menu on right-click on cards
document.addEventListener('contextmenu', (e) => {
const card = e.target.closest(this.cardSelector);
if (!card) {
this.hideMenu();
return;
}
e.preventDefault();
this.showMenu(e.clientX, e.clientY, card);
});
// Handle menu item clicks
this.menu.addEventListener('click', (e) => {

View File

@@ -0,0 +1,110 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { state } from '../../state/index.js';
import { bulkManager } from '../../managers/BulkManager.js';
import { updateElementText } from '../../utils/i18nHelpers.js';
export class BulkContextMenu extends BaseContextMenu {
constructor() {
super('bulkContextMenu', '.model-card.selected');
this.setupBulkMenuItems();
}
setupBulkMenuItems() {
if (!this.menu) return;
// Update menu items visibility based on current model type
this.updateMenuItemsForModelType();
// Update selected count in header
this.updateSelectedCountHeader();
}
updateMenuItemsForModelType() {
const currentModelType = state.currentPageType;
const config = bulkManager.actionConfig[currentModelType];
if (!config) return;
// Update button visibility based on model type
const addTagsItem = this.menu.querySelector('[data-action="add-tags"]');
const setBaseModelItem = this.menu.querySelector('[data-action="set-base-model"]');
const sendToWorkflowAppendItem = this.menu.querySelector('[data-action="send-to-workflow-append"]');
const sendToWorkflowReplaceItem = this.menu.querySelector('[data-action="send-to-workflow-replace"]');
const copyAllItem = this.menu.querySelector('[data-action="copy-all"]');
const refreshAllItem = this.menu.querySelector('[data-action="refresh-all"]');
const moveAllItem = this.menu.querySelector('[data-action="move-all"]');
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
if (sendToWorkflowAppendItem) {
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
}
if (sendToWorkflowReplaceItem) {
sendToWorkflowReplaceItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
}
if (copyAllItem) {
copyAllItem.style.display = config.copyAll ? 'flex' : 'none';
}
if (refreshAllItem) {
refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none';
}
if (moveAllItem) {
moveAllItem.style.display = config.moveAll ? 'flex' : 'none';
}
if (deleteAllItem) {
deleteAllItem.style.display = config.deleteAll ? 'flex' : 'none';
}
if (addTagsItem) {
addTagsItem.style.display = config.addTags ? 'flex' : 'none';
}
if (setBaseModelItem) {
setBaseModelItem.style.display = 'flex'; // Base model editing is available for all model types
}
}
updateSelectedCountHeader() {
const headerElement = this.menu.querySelector('.bulk-context-header');
if (headerElement) {
updateElementText(headerElement, 'loras.bulkOperations.selected', { count: state.selectedModels.size });
}
}
showMenu(x, y, card) {
this.updateMenuItemsForModelType();
this.updateSelectedCountHeader();
super.showMenu(x, y, card);
}
handleMenuAction(action, menuItem) {
switch (action) {
case 'add-tags':
bulkManager.showBulkAddTagsModal();
break;
case 'set-base-model':
bulkManager.showBulkBaseModelModal();
break;
case 'send-to-workflow-append':
bulkManager.sendAllModelsToWorkflow(false);
break;
case 'send-to-workflow-replace':
bulkManager.sendAllModelsToWorkflow(true);
break;
case 'copy-all':
bulkManager.copyAllModelsSyntax();
break;
case 'refresh-all':
bulkManager.refreshAllMetadata();
break;
case 'move-all':
window.moveManager.showMoveModal('bulk');
break;
case 'delete-all':
bulkManager.showBulkDeleteModal();
break;
case 'clear':
bulkManager.clearSelection();
break;
default:
console.warn(`Unknown bulk action: ${action}`);
}
}
}

View File

@@ -25,10 +25,10 @@ export const ModelContextMenuMixin = {
try {
await this.saveModelMetadata(filePath, { preview_nsfw_level: level });
showToast(`Content rating set to ${getNSFWLevelName(level)}`, 'success');
showToast('toast.contextMenu.contentRatingSet', { level: getNSFWLevelName(level) }, 'success');
this.nsfwSelector.style.display = 'none';
} catch (error) {
showToast(`Failed to set content rating: ${error.message}`, 'error');
showToast('toast.contextMenu.contentRatingFailed', { message: error.message }, 'error');
}
});
});
@@ -147,7 +147,7 @@ export const ModelContextMenuMixin = {
const data = await response.json();
if (data.success) {
showToast('Model successfully re-linked to Civitai', 'success');
showToast('toast.contextMenu.relinkSuccess', {}, 'success');
// Reload the current view to show updated data
await this.resetAndReload();
} else {
@@ -155,7 +155,7 @@ export const ModelContextMenuMixin = {
}
} catch (error) {
console.error('Error re-linking model:', error);
showToast(`Error: ${error.message}`, 'error');
showToast('toast.contextMenu.relinkFailed', { message: error.message }, 'error');
} finally {
state.loadingManager.hide();
}
@@ -211,10 +211,10 @@ export const ModelContextMenuMixin = {
if (this.currentCard.querySelector('.fa-globe')) {
this.currentCard.querySelector('.fa-globe').click();
} else {
showToast('Please fetch metadata from CivitAI first', 'info');
showToast('toast.contextMenu.fetchMetadataFirst', {}, 'info');
}
} else {
showToast('No CivitAI information available', 'info');
showToast('toast.contextMenu.noCivitaiInfo', {}, 'info');
}
return true;
case 'relink-civitai':
@@ -232,7 +232,7 @@ export const ModelContextMenuMixin = {
async downloadExampleImages() {
const modelHash = this.currentCard.dataset.sha256;
if (!modelHash) {
showToast('Model hash not available', 'error');
showToast('toast.contextMenu.missingHash', {}, 'error');
return;
}

View File

@@ -99,7 +99,7 @@ export class RecipeContextMenu extends BaseContextMenu {
copyRecipeSyntax() {
const recipeId = this.currentCard.dataset.id;
if (!recipeId) {
showToast('Cannot copy recipe: Missing recipe ID', 'error');
showToast('recipes.contextMenu.copyRecipe.missingId', {}, 'error');
return;
}
@@ -114,7 +114,7 @@ export class RecipeContextMenu extends BaseContextMenu {
})
.catch(err => {
console.error('Failed to copy recipe syntax: ', err);
showToast('Failed to copy recipe syntax', 'error');
showToast('recipes.contextMenu.copyRecipe.failed', {}, 'error');
});
}
@@ -122,7 +122,7 @@ export class RecipeContextMenu extends BaseContextMenu {
sendRecipeToWorkflow(replaceMode) {
const recipeId = this.currentCard.dataset.id;
if (!recipeId) {
showToast('Cannot send recipe: Missing recipe ID', 'error');
showToast('recipes.contextMenu.sendRecipe.missingId', {}, 'error');
return;
}
@@ -137,14 +137,14 @@ export class RecipeContextMenu extends BaseContextMenu {
})
.catch(err => {
console.error('Failed to send recipe to workflow: ', err);
showToast('Failed to send recipe to workflow', 'error');
showToast('recipes.contextMenu.sendRecipe.failed', {}, 'error');
});
}
// View all LoRAs in the recipe
viewRecipeLoRAs(recipeId) {
if (!recipeId) {
showToast('Cannot view LoRAs: Missing recipe ID', 'error');
showToast('recipes.contextMenu.viewLoras.missingId', {}, 'error');
return;
}
@@ -171,19 +171,19 @@ export class RecipeContextMenu extends BaseContextMenu {
// Navigate to the LoRAs page
window.location.href = '/loras';
} else {
showToast('No LoRAs found in this recipe', 'info');
showToast('recipes.contextMenu.viewLoras.noLorasFound', {}, 'info');
}
})
.catch(error => {
console.error('Error loading recipe LoRAs:', error);
showToast('Error loading recipe LoRAs: ' + error.message, 'error');
showToast('recipes.contextMenu.viewLoras.loadError', { message: error.message }, 'error');
});
}
// Download missing LoRAs
async downloadMissingLoRAs(recipeId) {
if (!recipeId) {
showToast('Cannot download LoRAs: Missing recipe ID', 'error');
showToast('recipes.contextMenu.downloadMissing.missingId', {}, 'error');
return;
}
@@ -196,7 +196,7 @@ export class RecipeContextMenu extends BaseContextMenu {
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted);
if (missingLoras.length === 0) {
showToast('No missing LoRAs to download', 'info');
showToast('recipes.contextMenu.downloadMissing.noMissingLoras', {}, 'info');
return;
}
@@ -234,7 +234,7 @@ export class RecipeContextMenu extends BaseContextMenu {
const validLoras = lorasWithVersionInfo.filter(lora => lora !== null);
if (validLoras.length === 0) {
showToast('Failed to get information for missing LoRAs', 'error');
showToast('recipes.contextMenu.downloadMissing.getInfoFailed', {}, 'error');
return;
}
@@ -275,7 +275,7 @@ export class RecipeContextMenu extends BaseContextMenu {
window.importManager.downloadMissingLoras(recipeData, recipeId);
} catch (error) {
console.error('Error downloading missing LoRAs:', error);
showToast('Error preparing LoRAs for download: ' + error.message, 'error');
showToast('recipes.contextMenu.downloadMissing.prepareError', { message: error.message }, 'error');
} finally {
if (state.loadingManager) {
state.loadingManager.hide();

View File

@@ -2,4 +2,25 @@ export { LoraContextMenu } from './LoraContextMenu.js';
export { RecipeContextMenu } from './RecipeContextMenu.js';
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
export { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { LoraContextMenu } from './LoraContextMenu.js';
import { RecipeContextMenu } from './RecipeContextMenu.js';
import { CheckpointContextMenu } from './CheckpointContextMenu.js';
import { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
// Factory method to create page-specific context menu instances
export function createPageContextMenu(pageType) {
switch (pageType) {
case 'loras':
return new LoraContextMenu();
case 'recipes':
return new RecipeContextMenu();
case 'checkpoints':
return new CheckpointContextMenu();
case 'embeddings':
return new EmbeddingContextMenu();
default:
return null;
}
}

View File

@@ -26,7 +26,7 @@ export class DuplicatesManager {
this.duplicateGroups = data.duplicate_groups || [];
if (this.duplicateGroups.length === 0) {
showToast('No duplicate recipes found', 'info');
showToast('toast.duplicates.noDuplicatesFound', { type: 'recipes' }, 'info');
return false;
}
@@ -34,7 +34,7 @@ export class DuplicatesManager {
return true;
} catch (error) {
console.error('Error finding duplicates:', error);
showToast('Failed to find duplicates: ' + error.message, 'error');
showToast('toast.duplicates.findFailed', { message: error.message }, 'error');
return false;
}
}
@@ -325,7 +325,7 @@ export class DuplicatesManager {
async deleteSelectedDuplicates() {
if (this.selectedForDeletion.size === 0) {
showToast('No recipes selected for deletion', 'info');
showToast('toast.duplicates.noItemsSelected', { type: 'recipes' }, 'info');
return;
}
@@ -340,7 +340,7 @@ export class DuplicatesManager {
modalManager.showModal('duplicateDeleteModal');
} catch (error) {
console.error('Error preparing delete:', error);
showToast('Error: ' + error.message, 'error');
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
}
}
@@ -371,7 +371,7 @@ export class DuplicatesManager {
throw new Error(data.error || 'Unknown error deleting recipes');
}
showToast(`Successfully deleted ${data.total_deleted} recipes`, 'success');
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: 'recipes' }, 'success');
// Exit duplicate mode if deletions were successful
if (data.total_deleted > 0) {
@@ -380,7 +380,7 @@ export class DuplicatesManager {
} catch (error) {
console.error('Error deleting recipes:', error);
showToast('Failed to delete recipes: ' + error.message, 'error');
showToast('toast.duplicates.deleteFailed', { type: 'recipes', message: error.message }, 'error');
}
}
}

View File

@@ -3,6 +3,8 @@ import { toggleTheme } from '../utils/uiHelpers.js';
import { SearchManager } from '../managers/SearchManager.js';
import { FilterManager } from '../managers/FilterManager.js';
import { initPageState } from '../state/index.js';
import { getStorageItem } from '../utils/storageHelpers.js';
import { updateElementAttribute } from '../utils/i18nHelpers.js';
/**
* Header.js - Manages the application header behavior across different pages
@@ -47,21 +49,17 @@ export class HeaderManager {
// Handle theme toggle
const themeToggle = document.querySelector('.theme-toggle');
if (themeToggle) {
// Set initial state based on current theme
const currentTheme = localStorage.getItem('lm_theme') || 'auto';
const currentTheme = getStorageItem('theme') || 'auto';
themeToggle.classList.add(`theme-${currentTheme}`);
themeToggle.addEventListener('click', () => {
// Use i18nHelpers to update themeToggle's title
this.updateThemeTooltip(themeToggle, currentTheme);
themeToggle.addEventListener('click', async () => {
if (typeof toggleTheme === 'function') {
const newTheme = toggleTheme();
// Update tooltip based on next toggle action
if (newTheme === 'light') {
themeToggle.title = "Switch to dark theme";
} else if (newTheme === 'dark') {
themeToggle.title = "Switch to auto theme";
} else {
themeToggle.title = "Switch to light theme";
}
// Use i18nHelpers to update themeToggle's title
this.updateThemeTooltip(themeToggle, newTheme);
}
});
}
@@ -125,29 +123,43 @@ export class HeaderManager {
// Hide search functionality on Statistics page
this.updateHeaderForPage();
}
updateHeaderForPage() {
const headerSearch = document.getElementById('headerSearch');
const searchInput = headerSearch?.querySelector('#searchInput');
const searchButtons = headerSearch?.querySelectorAll('button');
const placeholderKey = 'header.search.placeholders.' + this.currentPage;
if (this.currentPage === 'statistics' && headerSearch) {
headerSearch.classList.add('disabled');
// Disable search functionality
const searchInput = headerSearch.querySelector('#searchInput');
const searchButtons = headerSearch.querySelectorAll('button');
if (searchInput) {
searchInput.disabled = true;
searchInput.placeholder = 'Search not available on statistics page';
// Use i18nHelpers to update placeholder
updateElementAttribute(searchInput, 'placeholder', 'header.search.notAvailable', {}, 'Search not available on statistics page');
}
searchButtons.forEach(btn => btn.disabled = true);
searchButtons?.forEach(btn => btn.disabled = true);
} else if (headerSearch) {
headerSearch.classList.remove('disabled');
// Re-enable search functionality
const searchInput = headerSearch.querySelector('#searchInput');
const searchButtons = headerSearch.querySelectorAll('button');
if (searchInput) {
searchInput.disabled = false;
// Use i18nHelpers to update placeholder
updateElementAttribute(searchInput, 'placeholder', placeholderKey, {}, '');
}
searchButtons.forEach(btn => btn.disabled = false);
searchButtons?.forEach(btn => btn.disabled = false);
}
}
updateThemeTooltip(themeToggle, currentTheme) {
if (!themeToggle) return;
let key;
if (currentTheme === 'light') {
key = 'header.theme.switchToDark';
} else if (currentTheme === 'dark') {
key = 'header.theme.switchToLight';
} else {
key = 'header.theme.toggle';
}
// Use i18nHelpers to update title
updateElementAttribute(themeToggle, 'title', key, {}, '');
}
}

View File

@@ -122,7 +122,7 @@ export class ModelDuplicatesManager {
this.updateDuplicatesBadge(this.duplicateGroups.length);
if (this.duplicateGroups.length === 0) {
showToast('No duplicate models found', 'info');
showToast('toast.duplicates.noDuplicatesFound', { type: this.modelType }, 'info');
return false;
}
@@ -130,7 +130,7 @@ export class ModelDuplicatesManager {
return true;
} catch (error) {
console.error('Error finding duplicates:', error);
showToast('Failed to find duplicates: ' + error.message, 'error');
showToast('toast.duplicates.findFailed', { message: error.message }, 'error');
return false;
}
}
@@ -594,7 +594,7 @@ export class ModelDuplicatesManager {
async deleteSelectedDuplicates() {
if (this.selectedForDeletion.size === 0) {
showToast('No models selected for deletion', 'info');
showToast('toast.duplicates.noItemsSelected', { type: this.modelType }, 'info');
return;
}
@@ -609,7 +609,7 @@ export class ModelDuplicatesManager {
modalManager.showModal('modelDuplicateDeleteModal');
} catch (error) {
console.error('Error preparing delete:', error);
showToast('Error: ' + error.message, 'error');
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
}
}
@@ -640,7 +640,7 @@ export class ModelDuplicatesManager {
throw new Error(data.error || 'Unknown error deleting models');
}
showToast(`Successfully deleted ${data.total_deleted} models`, 'success');
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: this.modelType }, 'success');
// If models were successfully deleted
if (data.total_deleted > 0) {
@@ -678,7 +678,7 @@ export class ModelDuplicatesManager {
} catch (error) {
console.error('Error deleting models:', error);
showToast('Failed to delete models: ' + error.message, 'error');
showToast('toast.duplicates.deleteFailed', { type: this.modelType, message: error.message }, 'error');
}
}
@@ -745,7 +745,7 @@ export class ModelDuplicatesManager {
// Check if already verified
if (this.verifiedGroups.has(groupHash)) {
showToast('This group has already been verified', 'info');
showToast('toast.models.verificationAlreadyDone', {}, 'info');
return;
}
@@ -793,14 +793,14 @@ export class ModelDuplicatesManager {
// Show appropriate toast message
if (mismatchedFiles.length > 0) {
showToast(`Verification complete. ${mismatchedFiles.length} file(s) have different actual hashes.`, 'warning');
showToast('toast.models.verificationCompleteMismatch', { count: mismatchedFiles.length }, 'warning');
} else {
showToast('Verification complete. All files are confirmed duplicates.', 'success');
showToast('toast.models.verificationCompleteSuccess', {}, 'success');
}
} catch (error) {
console.error('Error verifying hashes:', error);
showToast('Failed to verify hashes: ' + error.message, 'error');
showToast('toast.models.verificationFailed', { message: error.message }, 'error');
} finally {
// Hide loading state
state.loadingManager.hide();

View File

@@ -199,7 +199,7 @@ class RecipeCard {
// Get recipe ID
const recipeId = this.recipe.id;
if (!recipeId) {
showToast('Cannot send recipe: Missing recipe ID', 'error');
showToast('toast.recipes.cannotSend', {}, 'error');
return;
}
@@ -214,11 +214,11 @@ class RecipeCard {
})
.catch(err => {
console.error('Failed to send recipe to workflow: ', err);
showToast('Failed to send recipe to workflow', 'error');
showToast('toast.recipes.sendFailed', {}, 'error');
});
} catch (error) {
console.error('Error sending recipe to workflow:', error);
showToast('Error sending recipe to workflow', 'error');
showToast('toast.recipes.sendError', {}, 'error');
}
}
@@ -228,7 +228,7 @@ class RecipeCard {
const recipeId = this.recipe.id;
const filePath = this.recipe.file_path;
if (!recipeId) {
showToast('Cannot delete recipe: Missing recipe ID', 'error');
showToast('toast.recipes.cannotDelete', {}, 'error');
return;
}
@@ -278,7 +278,7 @@ class RecipeCard {
} catch (error) {
console.error('Error showing delete confirmation:', error);
showToast('Error showing delete confirmation', 'error');
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
}
}
@@ -287,7 +287,7 @@ class RecipeCard {
const recipeId = deleteModal.dataset.recipeId;
if (!recipeId) {
showToast('Cannot delete recipe: Missing recipe ID', 'error');
showToast('toast.recipes.cannotDelete', {}, 'error');
modalManager.closeModal('deleteModal');
return;
}
@@ -312,7 +312,7 @@ class RecipeCard {
return response.json();
})
.then(data => {
showToast('Recipe deleted successfully', 'success');
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
@@ -320,7 +320,7 @@ class RecipeCard {
})
.catch(error => {
console.error('Error deleting recipe:', error);
showToast('Error deleting recipe: ' + error.message, 'error');
showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
// Reset button state
deleteBtn.textContent = originalText;
@@ -333,12 +333,12 @@ class RecipeCard {
// Get recipe ID
const recipeId = this.recipe.id;
if (!recipeId) {
showToast('Cannot share recipe: Missing recipe ID', 'error');
showToast('toast.recipes.cannotShare', {}, 'error');
return;
}
// Show loading toast
showToast('Preparing recipe for sharing...', 'info');
showToast('toast.recipes.preparingForSharing', {}, 'info');
// Call the API to process the image with metadata
fetch(`/api/recipe/${recipeId}/share`)
@@ -363,15 +363,15 @@ class RecipeCard {
downloadLink.click();
document.body.removeChild(downloadLink);
showToast('Recipe download started', 'success');
showToast('toast.recipes.downloadStarted', {}, 'success');
})
.catch(error => {
console.error('Error sharing recipe:', error);
showToast('Error sharing recipe: ' + error.message, 'error');
showToast('toast.recipes.shareError', { message: error.message }, 'error');
});
} catch (error) {
console.error('Error sharing recipe:', error);
showToast('Error preparing recipe for sharing', 'error');
showToast('toast.recipes.sharePreparationError', {}, 'error');
}
}
}

View File

@@ -526,7 +526,7 @@ class RecipeModal {
updateRecipeMetadata(this.filePath, { title: newTitle })
.then(data => {
// Show success toast
showToast('Recipe name updated successfully', 'success');
showToast('toast.recipes.nameUpdated', {}, 'success');
// Update the current recipe object
this.currentRecipe.title = newTitle;
@@ -596,7 +596,7 @@ class RecipeModal {
updateRecipeMetadata(this.filePath, { tags: newTags })
.then(data => {
// Show success toast
showToast('Recipe tags updated successfully', 'success');
showToast('toast.recipes.tagsUpdated', {}, 'success');
// Update the current recipe object
this.currentRecipe.tags = newTags;
@@ -717,7 +717,7 @@ class RecipeModal {
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl })
.then(data => {
// Show success toast
showToast('Source URL updated successfully', 'success');
showToast('toast.recipes.sourceUrlUpdated', {}, 'success');
// Update source URL in the UI
sourceUrlText.textContent = newSourceUrl || 'No source URL';
@@ -778,7 +778,7 @@ class RecipeModal {
// Fetch recipe syntax from backend and copy to clipboard
async fetchAndCopyRecipeSyntax() {
if (!this.recipeId) {
showToast('No recipe ID available', 'error');
showToast('toast.recipes.noRecipeId', {}, 'error');
return;
}
@@ -800,7 +800,7 @@ class RecipeModal {
}
} catch (error) {
console.error('Error fetching recipe syntax:', error);
showToast(`Error copying recipe syntax: ${error.message}`, 'error');
showToast('toast.recipes.copyFailed', { message: error.message }, 'error');
}
}
@@ -817,7 +817,7 @@ class RecipeModal {
console.log("missingLoras", missingLoras);
if (missingLoras.length === 0) {
showToast('No missing LoRAs to download', 'info');
showToast('toast.recipes.noMissingLoras', {}, 'info');
return;
}
@@ -856,7 +856,7 @@ class RecipeModal {
const validLoras = lorasWithVersionInfo.filter(lora => lora !== null);
if (validLoras.length === 0) {
showToast('Failed to get information for missing LoRAs', 'error');
showToast('toast.recipes.missingLorasInfoFailed', {}, 'error');
return;
}
@@ -902,7 +902,7 @@ class RecipeModal {
window.importManager.downloadMissingLoras(recipeData, this.currentRecipe.id);
} catch (error) {
console.error("Error downloading missing LoRAs:", error);
showToast('Error preparing LoRAs for download', 'error');
showToast('toast.recipes.preparingForDownloadFailed', {}, 'error');
} finally {
state.loadingManager.hide();
}
@@ -988,7 +988,7 @@ class RecipeModal {
async reconnectLora(loraIndex, inputValue) {
if (!inputValue || !inputValue.trim()) {
showToast('Please enter a LoRA name or syntax', 'error');
showToast('toast.recipes.enterLoraName', {}, 'error');
return;
}
@@ -1026,7 +1026,7 @@ class RecipeModal {
this.currentRecipe.loras[loraIndex] = result.updated_lora;
// Show success message
showToast('LoRA reconnected successfully', 'success');
showToast('toast.recipes.reconnectedSuccessfully', {}, 'success');
// Refresh modal to show updated content
setTimeout(() => {
@@ -1037,11 +1037,11 @@ class RecipeModal {
loras: this.currentRecipe.loras
});
} else {
showToast(`Error: ${result.error}`, 'error');
showToast('toast.recipes.reconnectFailed', { message: result.error }, 'error');
}
} catch (error) {
console.error('Error reconnecting LoRA:', error);
showToast(`Error reconnecting LoRA: ${error.message}`, 'error');
showToast('toast.recipes.reconnectFailed', { message: error.message }, 'error');
} finally {
state.loadingManager.hide();
}

View File

@@ -0,0 +1,977 @@
/**
* SidebarManager - Manages hierarchical folder navigation sidebar
*/
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { translate } from '../utils/i18nHelpers.js';
export class SidebarManager {
constructor() {
this.pageControls = null;
this.pageType = null;
this.treeData = {};
this.selectedPath = '';
this.expandedNodes = new Set();
this.isVisible = true;
this.isPinned = false;
this.apiClient = null;
this.openDropdown = null;
this.hoverTimeout = null;
this.isHovering = false;
this.isInitialized = false;
this.displayMode = 'tree'; // 'tree' or 'list'
this.foldersList = [];
// Bind methods
this.handleTreeClick = this.handleTreeClick.bind(this);
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
this.handleDocumentClick = this.handleDocumentClick.bind(this);
this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this);
this.handlePinToggle = this.handlePinToggle.bind(this);
this.handleCollapseAll = this.handleCollapseAll.bind(this);
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseLeave = this.handleMouseLeave.bind(this);
this.handleHoverAreaEnter = this.handleHoverAreaEnter.bind(this);
this.handleHoverAreaLeave = this.handleHoverAreaLeave.bind(this);
this.updateContainerMargin = this.updateContainerMargin.bind(this);
this.handleDisplayModeToggle = this.handleDisplayModeToggle.bind(this);
this.handleFolderListClick = this.handleFolderListClick.bind(this);
}
async initialize(pageControls) {
// Clean up previous initialization if exists
if (this.isInitialized) {
this.cleanup();
}
this.pageControls = pageControls;
this.pageType = pageControls.pageType;
this.apiClient = getModelApiClient();
// Set initial sidebar state immediately (hidden by default)
this.setInitialSidebarState();
this.setupEventHandlers();
this.updateSidebarTitle();
this.restoreSidebarState();
await this.loadFolderTree();
this.restoreSelectedFolder();
// Apply final state with animation after everything is loaded
this.applyFinalSidebarState();
// Update container margin based on initial sidebar state
this.updateContainerMargin();
this.isInitialized = true;
console.log(`SidebarManager initialized for ${this.pageType} page`);
}
cleanup() {
if (!this.isInitialized) return;
// Clear any pending timeouts
if (this.hoverTimeout) {
clearTimeout(this.hoverTimeout);
this.hoverTimeout = null;
}
// Clean up event handlers
this.removeEventHandlers();
// Reset state
this.pageControls = null;
this.pageType = null;
this.treeData = {};
this.selectedPath = '';
this.expandedNodes = new Set();
this.openDropdown = null;
this.isHovering = false;
this.apiClient = null;
this.isInitialized = false;
// Reset container margin
const container = document.querySelector('.container');
if (container) {
container.style.marginLeft = '';
}
// Remove resize event listener
window.removeEventListener('resize', this.updateContainerMargin);
console.log('SidebarManager cleaned up');
}
removeEventHandlers() {
const pinToggleBtn = document.getElementById('sidebarPinToggle');
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
const folderTree = document.getElementById('sidebarFolderTree');
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
const sidebarHeader = document.getElementById('sidebarHeader');
const sidebar = document.getElementById('folderSidebar');
const hoverArea = document.getElementById('sidebarHoverArea');
const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle');
if (pinToggleBtn) {
pinToggleBtn.removeEventListener('click', this.handlePinToggle);
}
if (collapseAllBtn) {
collapseAllBtn.removeEventListener('click', this.handleCollapseAll);
}
if (folderTree) {
folderTree.removeEventListener('click', this.handleTreeClick);
}
if (sidebarBreadcrumbNav) {
sidebarBreadcrumbNav.removeEventListener('click', this.handleBreadcrumbClick);
}
if (sidebarHeader) {
sidebarHeader.removeEventListener('click', this.handleSidebarHeaderClick);
}
if (sidebar) {
sidebar.removeEventListener('mouseenter', this.handleMouseEnter);
sidebar.removeEventListener('mouseleave', this.handleMouseLeave);
}
if (hoverArea) {
hoverArea.removeEventListener('mouseenter', this.handleHoverAreaEnter);
hoverArea.removeEventListener('mouseleave', this.handleHoverAreaLeave);
}
// Remove document click handler
document.removeEventListener('click', this.handleDocumentClick);
// Remove resize event handler
window.removeEventListener('resize', this.updateContainerMargin);
if (displayModeToggleBtn) {
displayModeToggleBtn.removeEventListener('click', this.handleDisplayModeToggle);
}
}
async init() {
this.apiClient = getModelApiClient();
// Set initial sidebar state immediately (hidden by default)
this.setInitialSidebarState();
this.setupEventHandlers();
this.updateSidebarTitle();
this.restoreSidebarState();
await this.loadFolderTree();
this.restoreSelectedFolder();
// Apply final state with animation after everything is loaded
this.applyFinalSidebarState();
// Update container margin based on initial sidebar state
this.updateContainerMargin();
}
setInitialSidebarState() {
const sidebar = document.getElementById('folderSidebar');
const hoverArea = document.getElementById('sidebarHoverArea');
if (!sidebar || !hoverArea) return;
// Get stored pin state
const isPinned = getStorageItem(`${this.pageType}_sidebarPinned`, true);
this.isPinned = isPinned;
// Sidebar starts hidden by default (CSS handles this)
// Just set up the hover area state
if (window.innerWidth <= 1024) {
hoverArea.classList.add('disabled');
} else if (this.isPinned) {
hoverArea.classList.add('disabled');
} else {
hoverArea.classList.remove('disabled');
}
}
applyFinalSidebarState() {
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
this.updateAutoHideState();
});
}
updateSidebarTitle() {
const sidebarTitle = document.getElementById('sidebarTitle');
if (sidebarTitle) {
sidebarTitle.textContent = `${this.apiClient.apiConfig.config.displayName} Root`;
}
}
setupEventHandlers() {
// Sidebar header (root selection) - only trigger on title area
const sidebarHeader = document.getElementById('sidebarHeader');
if (sidebarHeader) {
sidebarHeader.addEventListener('click', this.handleSidebarHeaderClick);
}
// Pin toggle button
const pinToggleBtn = document.getElementById('sidebarPinToggle');
if (pinToggleBtn) {
pinToggleBtn.addEventListener('click', this.handlePinToggle);
}
// Collapse all button
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
if (collapseAllBtn) {
collapseAllBtn.addEventListener('click', this.handleCollapseAll);
}
// Tree click handler
const folderTree = document.getElementById('sidebarFolderTree');
if (folderTree) {
folderTree.addEventListener('click', this.handleTreeClick);
}
// Breadcrumb click handler
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
if (sidebarBreadcrumbNav) {
sidebarBreadcrumbNav.addEventListener('click', this.handleBreadcrumbClick);
}
// Hover detection for auto-hide
const sidebar = document.getElementById('folderSidebar');
const hoverArea = document.getElementById('sidebarHoverArea');
if (sidebar) {
sidebar.addEventListener('mouseenter', this.handleMouseEnter);
sidebar.addEventListener('mouseleave', this.handleMouseLeave);
}
if (hoverArea) {
hoverArea.addEventListener('mouseenter', this.handleHoverAreaEnter);
hoverArea.addEventListener('mouseleave', this.handleHoverAreaLeave);
}
// Close sidebar when clicking outside on mobile
document.addEventListener('click', (e) => {
if (window.innerWidth <= 1024 && this.isVisible) {
const sidebar = document.getElementById('folderSidebar');
if (sidebar && !sidebar.contains(e.target)) {
this.hideSidebar();
}
}
});
// Handle window resize
window.addEventListener('resize', () => {
this.updateAutoHideState();
this.updateContainerMargin();
});
// Add document click handler for closing dropdowns
document.addEventListener('click', this.handleDocumentClick);
// Add dedicated resize listener for container margin updates
window.addEventListener('resize', this.updateContainerMargin);
// Display mode toggle button
const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle');
if (displayModeToggleBtn) {
displayModeToggleBtn.addEventListener('click', this.handleDisplayModeToggle);
}
}
handleDocumentClick(event) {
// Close open dropdown when clicking outside
if (this.openDropdown && !event.target.closest('.breadcrumb-dropdown')) {
this.closeDropdown();
}
}
handleSidebarHeaderClick(event) {
// Only trigger root selection if clicking on the title area, not the buttons
if (!event.target.closest('.sidebar-header-actions')) {
this.selectFolder(null);
}
}
handlePinToggle(event) {
event.stopPropagation();
this.isPinned = !this.isPinned;
this.updateAutoHideState();
this.updatePinButton();
this.saveSidebarState();
this.updateContainerMargin();
}
handleCollapseAll(event) {
event.stopPropagation();
this.expandedNodes.clear();
this.renderFolderDisplay();
this.saveExpandedState();
}
handleMouseEnter() {
this.isHovering = true;
if (this.hoverTimeout) {
clearTimeout(this.hoverTimeout);
this.hoverTimeout = null;
}
if (!this.isPinned) {
this.showSidebar();
}
}
handleMouseLeave() {
this.isHovering = false;
if (!this.isPinned) {
this.hoverTimeout = setTimeout(() => {
if (!this.isHovering) {
this.hideSidebar();
}
}, 300);
}
}
handleHoverAreaEnter() {
if (!this.isPinned) {
this.showSidebar();
}
}
handleHoverAreaLeave() {
// Let the sidebar's mouse leave handler deal with hiding
}
showSidebar() {
const sidebar = document.getElementById('folderSidebar');
if (sidebar && !this.isPinned) {
sidebar.classList.add('hover-active');
this.isVisible = true;
this.updateContainerMargin();
}
}
hideSidebar() {
const sidebar = document.getElementById('folderSidebar');
if (sidebar && !this.isPinned) {
sidebar.classList.remove('hover-active');
this.isVisible = false;
this.updateContainerMargin();
}
}
updateAutoHideState() {
const sidebar = document.getElementById('folderSidebar');
const hoverArea = document.getElementById('sidebarHoverArea');
if (!sidebar || !hoverArea) return;
if (window.innerWidth <= 1024) {
// Mobile: always use collapsed state
sidebar.classList.remove('auto-hide', 'hover-active', 'visible');
sidebar.classList.add('collapsed');
hoverArea.classList.add('disabled');
this.isVisible = false;
} else if (this.isPinned) {
// Desktop pinned: always visible
sidebar.classList.remove('auto-hide', 'collapsed', 'hover-active');
sidebar.classList.add('visible');
hoverArea.classList.add('disabled');
this.isVisible = true;
} else {
// Desktop auto-hide: use hover detection
sidebar.classList.remove('collapsed', 'visible');
sidebar.classList.add('auto-hide');
hoverArea.classList.remove('disabled');
if (this.isHovering) {
sidebar.classList.add('hover-active');
this.isVisible = true;
} else {
sidebar.classList.remove('hover-active');
this.isVisible = false;
}
}
// Update container margin when sidebar state changes
this.updateContainerMargin();
}
// New method to update container margin based on sidebar state
updateContainerMargin() {
const container = document.querySelector('.container');
const sidebar = document.getElementById('folderSidebar');
if (!container || !sidebar) return;
// Reset margin to default
container.style.marginLeft = '';
// Only adjust margin if sidebar is visible and pinned
if ((this.isPinned || this.isHovering) && this.isVisible) {
const sidebarWidth = sidebar.offsetWidth;
const viewportWidth = window.innerWidth;
const containerWidth = container.offsetWidth;
// Check if there's enough space for both sidebar and container
// We need: sidebar width + container width + some padding < viewport width
if (sidebarWidth + containerWidth + sidebarWidth > viewportWidth) {
// Not enough space, push container to the right
container.style.marginLeft = `${sidebarWidth + 10}px`;
}
}
}
updatePinButton() {
const pinBtn = document.getElementById('sidebarPinToggle');
if (pinBtn) {
pinBtn.classList.toggle('active', this.isPinned);
pinBtn.title = this.isPinned
? translate('sidebar.unpinSidebar')
: translate('sidebar.pinSidebar');
}
}
async loadFolderTree() {
try {
if (this.displayMode === 'tree') {
const response = await this.apiClient.fetchUnifiedFolderTree();
this.treeData = response.tree || {};
} else {
const response = await this.apiClient.fetchModelFolders();
this.foldersList = response.folders || [];
}
this.renderFolderDisplay();
} catch (error) {
console.error('Failed to load folder data:', error);
this.renderEmptyState();
}
}
renderFolderDisplay() {
if (this.displayMode === 'tree') {
this.renderTree();
} else {
this.renderFolderList();
}
}
renderTree() {
const folderTree = document.getElementById('sidebarFolderTree');
if (!folderTree) return;
if (!this.treeData || Object.keys(this.treeData).length === 0) {
this.renderEmptyState();
return;
}
folderTree.innerHTML = this.renderTreeNode(this.treeData, '');
}
renderTreeNode(nodeData, basePath) {
const entries = Object.entries(nodeData);
if (entries.length === 0) return '';
return entries.map(([folderName, children]) => {
const currentPath = basePath ? `${basePath}/${folderName}` : folderName;
const hasChildren = Object.keys(children).length > 0;
const isExpanded = this.expandedNodes.has(currentPath);
const isSelected = this.selectedPath === currentPath;
return `
<div class="sidebar-tree-node" data-path="${currentPath}">
<div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''}">
<div class="sidebar-tree-expand-icon ${isExpanded ? 'expanded' : ''}"
style="${hasChildren ? '' : 'opacity: 0; pointer-events: none;'}">
<i class="fas fa-chevron-right"></i>
</div>
<i class="fas fa-folder sidebar-tree-folder-icon"></i>
<div class="sidebar-tree-folder-name" title="${folderName}">${folderName}</div>
</div>
${hasChildren ? `
<div class="sidebar-tree-children ${isExpanded ? 'expanded' : ''}">
${this.renderTreeNode(children, currentPath)}
</div>
` : ''}
</div>
`;
}).join('');
}
renderEmptyState() {
const folderTree = document.getElementById('sidebarFolderTree');
if (!folderTree) return;
folderTree.innerHTML = `
<div class="sidebar-tree-placeholder">
<i class="fas fa-folder-open"></i>
<div>No folders found</div>
</div>
`;
}
renderFolderList() {
const folderTree = document.getElementById('sidebarFolderTree');
if (!folderTree) return;
if (!this.foldersList || this.foldersList.length === 0) {
this.renderEmptyState();
return;
}
const foldersHtml = this.foldersList.map(folder => {
const displayName = folder === '' ? '/' : folder;
const isSelected = this.selectedPath === folder;
return `
<div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${folder}">
<div class="sidebar-node-content">
<i class="fas fa-folder sidebar-folder-icon"></i>
<div class="sidebar-folder-name" title="${displayName}">${displayName}</div>
</div>
</div>
`;
}).join('');
folderTree.innerHTML = foldersHtml;
}
handleTreeClick(event) {
if (this.displayMode === 'list') {
this.handleFolderListClick(event);
return;
}
const expandIcon = event.target.closest('.sidebar-tree-expand-icon');
const nodeContent = event.target.closest('.sidebar-tree-node-content');
if (expandIcon) {
// Toggle expand/collapse
const treeNode = expandIcon.closest('.sidebar-tree-node');
const path = treeNode.dataset.path;
const children = treeNode.querySelector('.sidebar-tree-children');
if (this.expandedNodes.has(path)) {
this.expandedNodes.delete(path);
expandIcon.classList.remove('expanded');
if (children) children.classList.remove('expanded');
} else {
this.expandedNodes.add(path);
expandIcon.classList.add('expanded');
if (children) children.classList.add('expanded');
}
this.saveExpandedState();
} else if (nodeContent) {
// Select folder
const treeNode = nodeContent.closest('.sidebar-tree-node');
const path = treeNode.dataset.path;
this.selectFolder(path);
}
}
handleBreadcrumbClick(event) {
const breadcrumbItem = event.target.closest('.sidebar-breadcrumb-item');
const dropdownItem = event.target.closest('.breadcrumb-dropdown-item');
if (dropdownItem) {
// Handle dropdown item selection
const path = dropdownItem.dataset.path || '';
this.selectFolder(path);
this.closeDropdown();
} else if (breadcrumbItem) {
// Handle breadcrumb item click
const path = breadcrumbItem.dataset.path || null; // null for showing all models
const isPlaceholder = breadcrumbItem.classList.contains('placeholder');
const isActive = breadcrumbItem.classList.contains('active');
const dropdown = breadcrumbItem.closest('.breadcrumb-dropdown');
if (isPlaceholder || (isActive && path === this.selectedPath)) {
// Open dropdown for placeholders or active items
// Close any open dropdown first
if (this.openDropdown && this.openDropdown !== dropdown) {
this.openDropdown.classList.remove('open');
}
// Toggle current dropdown
dropdown.classList.toggle('open');
// Update open dropdown reference
this.openDropdown = dropdown.classList.contains('open') ? dropdown : null;
} else {
// Navigate to the selected path
this.selectFolder(path);
}
}
}
closeDropdown() {
if (this.openDropdown) {
this.openDropdown.classList.remove('open');
this.openDropdown = null;
}
}
async selectFolder(path) {
// Update selected path
this.selectedPath = path;
// Update UI
this.updateTreeSelection();
this.updateBreadcrumbs();
this.updateSidebarHeader();
// Update page state
this.pageControls.pageState.activeFolder = path;
setStorageItem(`${this.pageType}_activeFolder`, path);
// Reload models with new filter
await this.pageControls.resetAndReload();
// Auto-hide sidebar on mobile after selection
if (window.innerWidth <= 1024) {
this.hideSidebar();
}
}
handleFolderListClick(event) {
const folderItem = event.target.closest('.sidebar-folder-item');
if (folderItem) {
const path = folderItem.dataset.path;
this.selectFolder(path);
}
}
handleDisplayModeToggle(event) {
event.stopPropagation();
this.displayMode = this.displayMode === 'tree' ? 'list' : 'tree';
this.updateDisplayModeButton();
this.updateCollapseAllButton();
this.updateSearchRecursiveOption();
this.saveDisplayMode();
this.loadFolderTree(); // Reload with new display mode
}
updateDisplayModeButton() {
const displayModeBtn = document.getElementById('sidebarDisplayModeToggle');
if (displayModeBtn) {
const icon = displayModeBtn.querySelector('i');
if (this.displayMode === 'tree') {
icon.className = 'fas fa-sitemap';
displayModeBtn.title = translate('sidebar.switchToListView');
} else {
icon.className = 'fas fa-list';
displayModeBtn.title = translate('sidebar.switchToTreeView');
}
}
}
updateCollapseAllButton() {
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
if (collapseAllBtn) {
if (this.displayMode === 'list') {
collapseAllBtn.disabled = true;
collapseAllBtn.classList.add('disabled');
collapseAllBtn.title = translate('sidebar.collapseAllDisabled');
} else {
collapseAllBtn.disabled = false;
collapseAllBtn.classList.remove('disabled');
collapseAllBtn.title = translate('sidebar.collapseAll');
}
}
}
updateSearchRecursiveOption() {
this.pageControls.pageState.searchOptions.recursive = this.displayMode === 'tree';
}
updateTreeSelection() {
const folderTree = document.getElementById('sidebarFolderTree');
if (!folderTree) return;
if (this.displayMode === 'list') {
// Remove all selections in list mode
folderTree.querySelectorAll('.sidebar-folder-item').forEach(item => {
item.classList.remove('selected');
});
// Add selection to current path
if (this.selectedPath !== null) {
const selectedItem = folderTree.querySelector(`[data-path="${this.selectedPath}"]`);
if (selectedItem) {
selectedItem.classList.add('selected');
}
}
} else {
folderTree.querySelectorAll('.sidebar-tree-node-content').forEach(node => {
node.classList.remove('selected');
});
if (this.selectedPath) {
const selectedNode = folderTree.querySelector(`[data-path="${this.selectedPath}"] .sidebar-tree-node-content`);
if (selectedNode) {
selectedNode.classList.add('selected');
this.expandPathParents(this.selectedPath);
}
}
}
}
expandPathParents(path) {
if (!path) return;
const parts = path.split('/');
let currentPath = '';
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
this.expandedNodes.add(currentPath);
}
this.renderTree();
}
// Get sibling folders for a given path level
getSiblingFolders(pathParts, level) {
if (level === 0) {
// Root level siblings are top-level folders
return Object.keys(this.treeData);
}
// Navigate to the parent folder to get siblings
let currentNode = this.treeData;
for (let i = 0; i < level; i++) {
if (!currentNode[pathParts[i]]) {
return [];
}
currentNode = currentNode[pathParts[i]];
}
return Object.keys(currentNode);
}
// Get child folders for a given path
getChildFolders(path) {
if (!path) {
return Object.keys(this.treeData);
}
const parts = path.split('/');
let currentNode = this.treeData;
for (const part of parts) {
if (!currentNode[part]) {
return [];
}
currentNode = currentNode[part];
}
return Object.keys(currentNode);
}
updateBreadcrumbs() {
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
if (!sidebarBreadcrumbNav) return;
const parts = this.selectedPath ? this.selectedPath.split('/') : [];
let currentPath = '';
// Start with root breadcrumb
const rootSiblings = Object.keys(this.treeData);
const breadcrumbs = [`
<div class="breadcrumb-dropdown">
<span class="sidebar-breadcrumb-item ${this.selectedPath == null ? 'active' : ''}" data-path="">
<i class="fas fa-home"></i> ${this.apiClient.apiConfig.config.displayName} root
</span>
</div>
`];
// Add separator and placeholder for next level if we're at root
if (!this.selectedPath) {
const nextLevelFolders = rootSiblings;
if (nextLevelFolders.length > 0) {
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
breadcrumbs.push(`
<div class="breadcrumb-dropdown">
<span class="sidebar-breadcrumb-item placeholder">
--
<span class="breadcrumb-dropdown-indicator">
<i class="fas fa-caret-down"></i>
</span>
</span>
<div class="breadcrumb-dropdown-menu">
${nextLevelFolders.map(folder => `
<div class="breadcrumb-dropdown-item" data-path="${folder}">
${folder}
</div>`).join('')
}
</div>
</div>
`);
}
}
// Add breadcrumb items for each path segment
parts.forEach((part, index) => {
currentPath = currentPath ? `${currentPath}/${part}` : part;
const isLast = index === parts.length - 1;
// Get siblings for this level
const siblings = this.getSiblingFolders(parts, index);
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
breadcrumbs.push(`
<div class="breadcrumb-dropdown">
<span class="sidebar-breadcrumb-item ${isLast ? 'active' : ''}" data-path="${currentPath}">
${part}
${siblings.length > 1 ? `
<span class="breadcrumb-dropdown-indicator">
<i class="fas fa-caret-down"></i>
</span>
` : ''}
</span>
${siblings.length > 1 ? `
<div class="breadcrumb-dropdown-menu">
${siblings.map(folder => `
<div class="breadcrumb-dropdown-item ${folder === part ? 'active' : ''}"
data-path="${currentPath.replace(part, folder)}">
${folder}
</div>`).join('')
}
</div>
` : ''}
</div>
`);
// Add separator and placeholder for next level if not the last item
if (isLast) {
const childFolders = this.getChildFolders(currentPath);
if (childFolders.length > 0) {
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
breadcrumbs.push(`
<div class="breadcrumb-dropdown">
<span class="sidebar-breadcrumb-item placeholder">
--
<span class="breadcrumb-dropdown-indicator">
<i class="fas fa-caret-down"></i>
</span>
</span>
<div class="breadcrumb-dropdown-menu">
${childFolders.map(folder => `
<div class="breadcrumb-dropdown-item" data-path="${currentPath}/${folder}">
${folder}
</div>`).join('')
}
</div>
</div>
`);
}
}
});
sidebarBreadcrumbNav.innerHTML = breadcrumbs.join('');
}
updateSidebarHeader() {
const sidebarHeader = document.getElementById('sidebarHeader');
if (!sidebarHeader) return;
if (this.selectedPath == null) {
sidebarHeader.classList.add('root-selected');
} else {
sidebarHeader.classList.remove('root-selected');
}
}
toggleSidebar() {
const sidebar = document.getElementById('folderSidebar');
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
if (!sidebar) return;
this.isVisible = !this.isVisible;
if (this.isVisible) {
sidebar.classList.remove('collapsed');
sidebar.classList.add('visible');
} else {
sidebar.classList.remove('visible');
sidebar.classList.add('collapsed');
}
if (toggleBtn) {
toggleBtn.classList.toggle('active', this.isVisible);
}
this.saveSidebarState();
}
closeSidebar() {
const sidebar = document.getElementById('folderSidebar');
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
if (!sidebar) return;
this.isVisible = false;
sidebar.classList.remove('visible');
sidebar.classList.add('collapsed');
if (toggleBtn) {
toggleBtn.classList.remove('active');
}
this.saveSidebarState();
}
restoreSidebarState() {
const isPinned = getStorageItem(`${this.pageType}_sidebarPinned`, true);
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
this.isPinned = isPinned;
this.expandedNodes = new Set(expandedPaths);
this.displayMode = displayMode;
this.updatePinButton();
this.updateDisplayModeButton();
this.updateCollapseAllButton();
this.updateSearchRecursiveOption();
}
restoreSelectedFolder() {
const activeFolder = getStorageItem(`${this.pageType}_activeFolder`);
if (activeFolder && typeof activeFolder === 'string') {
this.selectedPath = activeFolder;
this.updateTreeSelection();
this.updateBreadcrumbs();
this.updateSidebarHeader();
} else {
this.selectedPath = '';
this.updateSidebarHeader();
this.updateBreadcrumbs(); // Always update breadcrumbs
}
// Removed hidden class toggle since breadcrumbs are always visible now
}
saveSidebarState() {
setStorageItem(`${this.pageType}_sidebarPinned`, this.isPinned);
}
saveExpandedState() {
setStorageItem(`${this.pageType}_expandedNodes`, Array.from(this.expandedNodes));
}
saveDisplayMode() {
setStorageItem(`${this.pageType}_displayMode`, this.displayMode);
}
async refresh() {
await this.loadFolderTree();
this.restoreSelectedFolder();
}
destroy() {
this.cleanup();
}
}
// Create and export global instance
export const sidebarManager = new SidebarManager();

View File

@@ -47,7 +47,7 @@ export class CheckpointsControls extends PageControls {
// No clearCustomFilter implementation is needed for checkpoints
// as custom filters are currently only used for LoRAs
clearCustomFilter: async () => {
showToast('No custom filter to clear', 'info');
showToast('toast.filters.noCustomFilterToClear', {}, 'info');
}
};

View File

@@ -47,7 +47,7 @@ export class EmbeddingsControls extends PageControls {
// No clearCustomFilter implementation is needed for embeddings
// as custom filters are currently only used for LoRAs
clearCustomFilter: async () => {
showToast('No custom filter to clear', 'info');
showToast('toast.filters.noCustomFilterToClear', {}, 'info');
}
};

View File

@@ -2,6 +2,7 @@
import { getCurrentPageState, setCurrentPageType } from '../../state/index.js';
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js';
import { showToast } from '../../utils/uiHelpers.js';
import { sidebarManager } from '../SidebarManager.js';
/**
* PageControls class - Unified control management for model pages
@@ -23,6 +24,9 @@ export class PageControls {
// Store API methods
this.api = null;
// Use global sidebar manager
this.sidebarManager = sidebarManager;
// Initialize event listeners
this.initEventListeners();
@@ -55,6 +59,20 @@ export class PageControls {
registerAPI(api) {
this.api = api;
console.log(`API methods registered for ${this.pageType} page`);
// Initialize sidebar manager after API is registered
this.initSidebarManager();
}
/**
* Initialize sidebar manager
*/
async initSidebarManager() {
try {
await this.sidebarManager.initialize(this);
} catch (error) {
console.error('Failed to initialize SidebarManager:', error);
}
}
/**
@@ -72,17 +90,6 @@ export class PageControls {
});
}
// Use event delegation for folder tags - this is the key fix
const folderTagsContainer = document.querySelector('.folder-tags-container');
if (folderTagsContainer) {
folderTagsContainer.addEventListener('click', (e) => {
const tag = e.target.closest('.tag');
if (tag) {
this.handleFolderClick(tag);
}
});
}
// Refresh button handler
const refreshBtn = document.querySelector('[data-action="refresh"]');
if (refreshBtn) {
@@ -92,12 +99,6 @@ export class PageControls {
// Initialize dropdown functionality
this.initDropdowns();
// Toggle folders button
const toggleFoldersBtn = document.querySelector('.toggle-folders-btn');
if (toggleFoldersBtn) {
toggleFoldersBtn.addEventListener('click', () => this.toggleFolderTags());
}
// Clear custom filter handler
const clearFilterBtn = document.querySelector('.clear-filter');
if (clearFilterBtn) {
@@ -199,130 +200,6 @@ export class PageControls {
}
}
/**
* Toggle folder selection
* @param {HTMLElement} tagElement - The folder tag element that was clicked
*/
handleFolderClick(tagElement) {
const folder = tagElement.dataset.folder;
const wasActive = tagElement.classList.contains('active');
document.querySelectorAll('.folder-tags .tag').forEach(t => {
t.classList.remove('active');
});
if (!wasActive) {
tagElement.classList.add('active');
this.pageState.activeFolder = folder;
setStorageItem(`${this.pageType}_activeFolder`, folder);
} else {
this.pageState.activeFolder = null;
setStorageItem(`${this.pageType}_activeFolder`, null);
}
this.resetAndReload();
}
/**
* Restore folder filter from storage
*/
restoreFolderFilter() {
const activeFolder = getStorageItem(`${this.pageType}_activeFolder`);
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
if (folderTag) {
folderTag.classList.add('active');
this.pageState.activeFolder = activeFolder;
this.filterByFolder(activeFolder);
}
}
/**
* Filter displayed cards by folder
* @param {string} folderPath - Folder path to filter by
*/
filterByFolder(folderPath) {
const cardSelector = this.pageType === 'loras' ? '.model-card' : '.checkpoint-card';
document.querySelectorAll(cardSelector).forEach(card => {
card.style.display = card.dataset.folder === folderPath ? '' : 'none';
});
}
/**
* Update the folder tags display with new folder list
* @param {Array} folders - List of folder names
*/
updateFolderTags(folders) {
const folderTagsContainer = document.querySelector('.folder-tags');
if (!folderTagsContainer) return;
// Keep track of currently selected folder
const currentFolder = this.pageState.activeFolder;
// Create HTML for folder tags
const tagsHTML = folders.map(folder => {
const isActive = folder === currentFolder;
return `<div class="tag ${isActive ? 'active' : ''}" data-folder="${folder}">${folder}</div>`;
}).join('');
// Update the container
folderTagsContainer.innerHTML = tagsHTML;
// Scroll active folder into view (no need to reattach click handlers)
const activeTag = folderTagsContainer.querySelector(`.tag[data-folder="${currentFolder}"]`);
if (activeTag) {
activeTag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
/**
* Toggle visibility of folder tags
*/
toggleFolderTags() {
const folderTags = document.querySelector('.folder-tags');
const toggleBtn = document.querySelector('.toggle-folders-btn i');
if (folderTags) {
folderTags.classList.toggle('collapsed');
if (folderTags.classList.contains('collapsed')) {
// Change icon to indicate folders are hidden
toggleBtn.className = 'fas fa-folder-plus';
toggleBtn.parentElement.title = 'Show folder tags';
setStorageItem('folderTagsCollapsed', 'true');
} else {
// Change icon to indicate folders are visible
toggleBtn.className = 'fas fa-folder-minus';
toggleBtn.parentElement.title = 'Hide folder tags';
setStorageItem('folderTagsCollapsed', 'false');
}
}
}
/**
* Initialize folder tags visibility based on stored preference
*/
initFolderTagsVisibility() {
const isCollapsed = getStorageItem('folderTagsCollapsed');
if (isCollapsed) {
const folderTags = document.querySelector('.folder-tags');
const toggleBtn = document.querySelector('.toggle-folders-btn i');
if (folderTags) {
folderTags.classList.add('collapsed');
}
if (toggleBtn) {
toggleBtn.className = 'fas fa-folder-plus';
toggleBtn.parentElement.title = 'Show folder tags';
}
} else {
const toggleBtn = document.querySelector('.toggle-folders-btn i');
if (toggleBtn) {
toggleBtn.className = 'fas fa-folder-minus';
toggleBtn.parentElement.title = 'Hide folder tags';
}
}
}
/**
* Load sort preference from storage
*/
@@ -408,9 +285,14 @@ export class PageControls {
try {
await this.api.resetAndReload(updateFolders);
// Refresh sidebar after reload if folders were updated
if (updateFolders && this.sidebarManager) {
await this.sidebarManager.refresh();
}
} catch (error) {
console.error(`Error reloading ${this.pageType}:`, error);
showToast(`Failed to reload ${this.pageType}: ${error.message}`, 'error');
showToast('toast.controls.reloadFailed', { pageType: this.pageType, message: error.message }, 'error');
}
}
@@ -426,9 +308,14 @@ export class PageControls {
try {
await this.api.refreshModels(fullRebuild);
// Refresh sidebar after rebuild
if (this.sidebarManager) {
await this.sidebarManager.refresh();
}
} catch (error) {
console.error(`Error ${fullRebuild ? 'rebuilding' : 'refreshing'} ${this.pageType}:`, error);
showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${this.pageType}: ${error.message}`, 'error');
showToast('toast.controls.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', pageType: this.pageType, message: error.message }, 'error');
}
if (window.modelDuplicatesManager) {
@@ -450,7 +337,7 @@ export class PageControls {
await this.api.fetchFromCivitai();
} catch (error) {
console.error('Error fetching metadata:', error);
showToast('Failed to fetch metadata: ' + error.message, 'error');
showToast('toast.controls.fetchMetadataFailed', { message: error.message }, 'error');
}
}
@@ -486,7 +373,7 @@ export class PageControls {
await this.api.clearCustomFilter();
} catch (error) {
console.error('Error clearing custom filter:', error);
showToast('Failed to clear custom filter: ' + error.message, 'error');
showToast('toast.controls.clearFilterFailed', { message: error.message }, 'error');
}
}
@@ -547,4 +434,15 @@ export class PageControls {
console.error('Model duplicates manager not available');
}
}
/**
* Clean up resources
*/
destroy() {
// Note: We don't destroy the global sidebar manager, just clean it up
// The global instance will be reused for other page controls
if (this.sidebarManager && this.sidebarManager.isInitialized) {
this.sidebarManager.cleanup();
}
}
}

View File

@@ -8,48 +8,47 @@ import { NSFW_LEVELS } from '../../utils/constants.js';
import { MODEL_TYPES } from '../../api/apiConfig.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { showDeleteModal } from '../../utils/modalUtils.js';
import { translate } from '../../utils/i18nHelpers.js';
import { eventManager } from '../../utils/EventManager.js';
// Add global event delegation handlers
// Add global event delegation handlers using event manager
export function setupModelCardEventDelegation(modelType) {
const gridElement = document.getElementById('modelGrid');
if (!gridElement) return;
// Remove any existing handler first
eventManager.removeHandler('click', 'modelCard-delegation');
// Remove any existing event listener to prevent duplication
gridElement.removeEventListener('click', gridElement._handleModelCardEvent);
// Create event handler with modelType context
const handleModelCardEvent = (event) => handleModelCardEvent_internal(event, modelType);
// Add the event delegation handler
gridElement.addEventListener('click', handleModelCardEvent);
// Store reference to the handler for cleanup
gridElement._handleModelCardEvent = handleModelCardEvent;
// Register model card event delegation with event manager
eventManager.addHandler('click', 'modelCard-delegation', (event) => {
return handleModelCardEvent_internal(event, modelType);
}, {
priority: 60, // Medium priority for model card interactions
targetSelector: '#modelGrid',
skipWhenModalOpen: false // Allow model card interactions even when modals are open (for some actions)
});
}
// Event delegation handler for all model card events
function handleModelCardEvent_internal(event, modelType) {
// Find the closest card element
const card = event.target.closest('.model-card');
if (!card) return;
if (!card) return false; // Continue with other handlers
// Handle specific elements within the card
if (event.target.closest('.toggle-blur-btn')) {
event.stopPropagation();
toggleBlurContent(card);
return;
return true; // Stop propagation
}
if (event.target.closest('.show-content-btn')) {
event.stopPropagation();
showBlurredContent(card);
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-star')) {
event.stopPropagation();
toggleFavorite(card);
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-globe')) {
@@ -57,41 +56,42 @@ function handleModelCardEvent_internal(event, modelType) {
if (card.dataset.from_civitai === 'true') {
openCivitai(card.dataset.filepath);
}
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-paper-plane')) {
event.stopPropagation();
handleSendToWorkflow(card, event.shiftKey, modelType);
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-copy')) {
event.stopPropagation();
handleCopyAction(card, modelType);
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-trash')) {
event.stopPropagation();
showDeleteModal(card.dataset.filepath);
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-image')) {
event.stopPropagation();
getModelApiClient().replaceModelPreview(card.dataset.filepath);
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-folder-open')) {
event.stopPropagation();
handleExampleImagesAccess(card, modelType);
return;
return true; // Stop propagation
}
// If no specific element was clicked, handle the card click (show modal or toggle selection)
handleCardClick(card, modelType);
return false; // Continue with other handlers (e.g., bulk selection)
}
// Helper functions for event handling
@@ -142,13 +142,13 @@ async function toggleFavorite(card) {
});
if (newFavoriteState) {
showToast('Added to favorites', 'success');
showToast('modelCard.favorites.added', {}, 'success');
} else {
showToast('Removed from favorites', 'success');
showToast('modelCard.favorites.removed', {}, 'success');
}
} catch (error) {
console.error('Failed to update favorite status:', error);
showToast('Failed to update favorite status', 'error');
showToast('modelCard.favorites.updateFailed', {}, 'error');
}
}
@@ -160,7 +160,7 @@ function handleSendToWorkflow(card, replaceMode, modelType) {
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
} else {
// Checkpoint send functionality - to be implemented
showToast('Send checkpoint to workflow - feature to be implemented', 'info');
showToast('modelCard.sendToWorkflow.checkpointNotImplemented', {}, 'info');
}
}
@@ -170,7 +170,8 @@ function handleCopyAction(card, modelType) {
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
// Checkpoint copy functionality - copy checkpoint name
const checkpointName = card.dataset.file_name;
copyToClipboard(checkpointName, 'Checkpoint name copied');
const message = translate('modelCard.actions.checkpointNameCopied', {}, 'Checkpoint name copied');
copyToClipboard(checkpointName, message);
} else if (modelType === MODEL_TYPES.EMBEDDING) {
const embeddingName = card.dataset.file_name;
copyToClipboard(embeddingName, 'Embedding name copied');
@@ -195,7 +196,7 @@ async function handleExampleImagesAccess(card, modelType) {
}
} catch (error) {
console.error('Error checking for example images:', error);
showToast('Error checking for example images', 'error');
showToast('modelCard.exampleImages.checkError', {}, 'error');
}
}
@@ -214,7 +215,7 @@ function handleCardClick(card, modelType) {
}
}
function showModelModalFromCard(card, modelType) {
async function showModelModalFromCard(card, modelType) {
// Get the appropriate preview versions map
const previewVersionsKey = modelType;
const previewVersions = state.pages[previewVersionsKey]?.previewVersions || new Map();
@@ -246,7 +247,7 @@ function showModelModalFromCard(card, modelType) {
})
};
showModelModal(modelMeta, modelType);
await showModelModal(modelMeta, modelType);
}
// Function to show the example access modal (generalized for lora and checkpoint)
@@ -277,7 +278,7 @@ function showExampleAccessModal(card, modelType) {
// Get the model hash
const modelHash = card.dataset.sha256;
if (!modelHash) {
showToast('Missing model hash information.', 'error');
showToast('modelCard.exampleImages.missingHash', {}, 'error');
return;
}
@@ -298,7 +299,8 @@ function showExampleAccessModal(card, modelType) {
};
} else {
downloadBtn.classList.add('disabled');
downloadBtn.setAttribute('title', 'No remote example images available for this model on Civitai');
const noRemoteImagesTitle = translate('modelCard.exampleImages.noRemoteImagesAvailable', {}, 'No remote example images available for this model on Civitai');
downloadBtn.setAttribute('title', noRemoteImagesTitle);
downloadBtn.onclick = null;
}
}
@@ -306,7 +308,7 @@ function showExampleAccessModal(card, modelType) {
// Set up import button
const importBtn = modal.querySelector('#importExamplesBtn');
if (importBtn) {
importBtn.onclick = () => {
importBtn.onclick = async () => {
modalManager.closeModal('exampleAccessModal');
// Get the model data from card dataset (works for both lora and checkpoint)
@@ -333,7 +335,7 @@ function showExampleAccessModal(card, modelType) {
}
// Show the model modal
showModelModal(modelMeta, modelType);
await showModelModal(modelMeta, modelType);
// Scroll to import area after modal is visible
setTimeout(() => {
@@ -429,14 +431,14 @@ export function createModelCard(model, modelType) {
const previewUrl = model.preview_url || '/loras_static/images/no-preview.png';
const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl;
// Determine NSFW warning text based on level
let nsfwText = "Mature Content";
// Determine NSFW warning text based on level with i18n support
let nsfwText = translate('modelCard.nsfw.matureContent', {}, 'Mature Content');
if (nsfwLevel >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
nsfwText = translate('modelCard.nsfw.xxxRated', {}, 'XXX-rated Content');
} else if (nsfwLevel >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
nsfwText = translate('modelCard.nsfw.xRated', {}, 'X-rated Content');
} else if (nsfwLevel >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
nsfwText = translate('modelCard.nsfw.rRated', {}, 'R-rated Content');
}
// Check if autoplayOnHover is enabled for video previews
@@ -447,22 +449,36 @@ export function createModelCard(model, modelType) {
// Get favorite status from model data
const isFavorite = model.favorite === true;
// Generate action icons based on model type
// Generate action icons based on model type with i18n support
const favoriteTitle = isFavorite ?
translate('modelCard.actions.removeFromFavorites', {}, 'Remove from favorites') :
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
const globeTitle = model.from_civitai ?
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
const sendTitle = translate('modelCard.actions.sendToWorkflow', {}, 'Send to ComfyUI (Click: Append, Shift+Click: Replace)');
const copyTitle = translate('modelCard.actions.copyLoRASyntax', {}, 'Copy LoRA Syntax');
const actionIcons = `
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}"
title="${isFavorite ? 'Remove from favorites' : 'Add to favorites'}">
title="${favoriteTitle}">
</i>
<i class="fas fa-globe"
title="${model.from_civitai ? 'View on Civitai' : 'Not available from Civitai'}"
title="${globeTitle}"
${!model.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
</i>
<i class="fas fa-paper-plane"
title="Send to ComfyUI (Click: Append, Shift+Click: Replace)">
title="${sendTitle}">
</i>
<i class="fas fa-copy"
title="Copy LoRA Syntax">
title="${copyTitle}">
</i>`;
// Generate UI text with i18n support
const toggleBlurTitle = translate('modelCard.actions.toggleBlur', {}, 'Toggle blur');
const showButtonText = translate('modelCard.actions.show', {}, 'Show');
const openExampleImagesTitle = translate('modelCard.actions.openExampleImages', {}, 'Open Example Images Folder');
card.innerHTML = `
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${isVideo ?
@@ -473,7 +489,7 @@ export function createModelCard(model, modelType) {
}
<div class="card-header">
${shouldBlur ?
`<button class="toggle-blur-btn" title="Toggle blur">
`<button class="toggle-blur-btn" title="${toggleBlurTitle}">
<i class="fas fa-eye"></i>
</button>` : ''}
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${model.base_model}">
@@ -487,7 +503,7 @@ export function createModelCard(model, modelType) {
<div class="nsfw-overlay">
<div class="nsfw-warning">
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
<button class="show-content-btn">${showButtonText}</button>
</div>
</div>
` : ''}
@@ -498,7 +514,7 @@ export function createModelCard(model, modelType) {
</div>
<div class="card-actions">
<i class="fas fa-folder-open"
title="Open Example Images Folder">
title="${openExampleImagesTitle}">
</i>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { showToast } from '../../utils/uiHelpers.js';
import { translate } from '../../utils/i18nHelpers.js';
/**
* ModelDescription.js
@@ -12,7 +13,7 @@ export function setupTabSwitching() {
const tabButtons = document.querySelectorAll('.showcase-tabs .tab-btn');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
button.addEventListener('click', async () => {
// Remove active class from all tabs
document.querySelectorAll('.showcase-tabs .tab-btn').forEach(btn =>
btn.classList.remove('active')
@@ -26,29 +27,65 @@ export function setupTabSwitching() {
const tabId = `${button.dataset.tab}-tab`;
document.getElementById(tabId).classList.add('active');
// If switching to description tab, make sure content is properly sized
// If switching to description tab, load content lazily
if (button.dataset.tab === 'description') {
const descriptionContent = document.querySelector('.model-description-content');
if (descriptionContent) {
const hasContent = descriptionContent.innerHTML.trim() !== '';
document.querySelector('.model-description-loading')?.classList.add('hidden');
// If no content, show a message
if (!hasContent) {
descriptionContent.innerHTML = '<div class="no-description">No model description available</div>';
descriptionContent.classList.remove('hidden');
}
}
await loadModelDescription();
}
});
});
}
/**
* Load model description lazily
*/
async function loadModelDescription() {
const descriptionContent = document.querySelector('.model-description-content');
const descriptionLoading = document.querySelector('.model-description-loading');
const showcaseSection = document.querySelector('.showcase-section');
if (!descriptionContent || !showcaseSection) return;
// Check if already loaded
if (descriptionContent.dataset.loaded === 'true') {
return;
}
const filePath = showcaseSection.dataset.filepath;
if (!filePath) return;
try {
// Show loading state
descriptionLoading?.classList.remove('hidden');
descriptionContent.classList.add('hidden');
// Fetch description from API
const { getModelApiClient } = await import('../../api/modelApiFactory.js');
const description = await getModelApiClient().fetchModelDescription(filePath);
// Update content
const noDescriptionText = translate('modals.model.description.noDescription', {}, 'No model description available');
descriptionContent.innerHTML = description || `<div class="no-description">${noDescriptionText}</div>`;
descriptionContent.dataset.loaded = 'true';
// Set up editing functionality
await setupModelDescriptionEditing(filePath);
} catch (error) {
console.error('Error loading model description:', error);
const failedText = translate('modals.model.description.failedToLoad', {}, 'Failed to load model description');
descriptionContent.innerHTML = `<div class="no-description">${failedText}</div>`;
} finally {
// Hide loading state
descriptionLoading?.classList.add('hidden');
descriptionContent.classList.remove('hidden');
}
}
/**
* Set up model description editing functionality
* @param {string} filePath - File path
*/
export function setupModelDescriptionEditing(filePath) {
export async function setupModelDescriptionEditing(filePath) {
const descContent = document.querySelector('.model-description-content');
const descContainer = document.querySelector('.model-description-container');
if (!descContent || !descContainer) return;
@@ -58,7 +95,9 @@ export function setupModelDescriptionEditing(filePath) {
if (!editBtn) {
editBtn = document.createElement('button');
editBtn.className = 'edit-model-description-btn';
editBtn.title = 'Edit model description';
// Set title using i18n
const editTitle = translate('modals.model.description.editTitle', {}, 'Edit model description');
editBtn.title = editTitle;
editBtn.innerHTML = '<i class="fas fa-pencil-alt"></i>';
descContainer.insertBefore(editBtn, descContent);
}
@@ -115,7 +154,7 @@ export function setupModelDescriptionEditing(filePath) {
}
if (!newValue) {
this.innerHTML = originalValue;
showToast('Description cannot be empty', 'error');
showToast('modals.model.description.validation.cannotBeEmpty', {}, 'error');
exitEditMode();
return;
}
@@ -123,10 +162,10 @@ export function setupModelDescriptionEditing(filePath) {
// Save to backend
const { getModelApiClient } = await import('../../api/modelApiFactory.js');
await getModelApiClient().saveModelMetadata(filePath, { modelDescription: newValue });
showToast('Model description updated', 'success');
showToast('modals.model.description.messages.updated', {}, 'success');
} catch (err) {
this.innerHTML = originalValue;
showToast('Failed to update model description', 'error');
showToast('modals.model.description.messages.updateFailed', {}, 'error');
} finally {
exitEditMode();
}

View File

@@ -2,8 +2,9 @@
* ModelMetadata.js
* Handles model metadata editing functionality - General version
*/
import { BASE_MODEL_CATEGORIES } from '../../utils/constants.js';
import { showToast } from '../../utils/uiHelpers.js';
import { BASE_MODELS } from '../../utils/constants.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
/**
@@ -82,7 +83,7 @@ export function setupModelNameEditing(filePath) {
sel.removeAllRanges();
sel.addRange(range);
showToast('Model name is limited to 100 characters', 'warning');
showToast('toast.models.nameTooLong', {}, 'warning');
}
});
@@ -97,7 +98,7 @@ export function setupModelNameEditing(filePath) {
if (!newModelName) {
// Restore original value if empty
this.textContent = originalValue;
showToast('Model name cannot be empty', 'error');
showToast('toast.models.nameCannotBeEmpty', {}, 'error');
exitEditMode();
return;
}
@@ -114,11 +115,11 @@ export function setupModelNameEditing(filePath) {
await getModelApiClient().saveModelMetadata(filePath, { model_name: newModelName });
showToast('Model name updated successfully', 'success');
showToast('toast.models.nameUpdatedSuccessfully', {}, 'success');
} catch (error) {
console.error('Error updating model name:', error);
this.textContent = originalValue; // Restore original model name
showToast('Failed to update model name', 'error');
showToast('toast.models.nameUpdateFailed', {}, 'error');
} finally {
exitEditMode();
}
@@ -171,21 +172,8 @@ export function setupBaseModelEditing(filePath) {
// Flag to track if a change was made
let valueChanged = false;
// Add options from BASE_MODELS constants
const baseModelCategories = {
'Stable Diffusion 1.x': [BASE_MODELS.SD_1_4, BASE_MODELS.SD_1_5, BASE_MODELS.SD_1_5_LCM, BASE_MODELS.SD_1_5_HYPER],
'Stable Diffusion 2.x': [BASE_MODELS.SD_2_0, BASE_MODELS.SD_2_1],
'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO],
'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER],
'Video Models': [BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO],
'Other Models': [
BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, BASE_MODELS.AURAFLOW,
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
BASE_MODELS.QWEN, BASE_MODELS.UNKNOWN
]
};
// Add options from BASE_MODEL_CATEGORIES constants
const baseModelCategories = BASE_MODEL_CATEGORIES;
// Create option groups for better organization
Object.entries(baseModelCategories).forEach(([category, models]) => {
@@ -293,9 +281,9 @@ async function saveBaseModel(filePath, originalValue) {
try {
await getModelApiClient().saveModelMetadata(filePath, { base_model: newBaseModel });
showToast('Base model updated successfully', 'success');
showToast('toast.models.baseModelUpdated', {}, 'success');
} catch (error) {
showToast('Failed to update base model', 'error');
showToast('toast.models.baseModelUpdateFailed', {}, 'error');
}
}
@@ -381,7 +369,7 @@ export function setupFileNameEditing(filePath) {
sel.addRange(range);
}
showToast('Invalid characters removed from filename', 'warning');
showToast('toast.models.invalidCharactersRemoved', {}, 'warning');
}
});
@@ -396,7 +384,7 @@ export function setupFileNameEditing(filePath) {
if (!newFileName) {
// Restore original value if empty
this.textContent = originalValue;
showToast('File name cannot be empty', 'error');
showToast('toast.models.filenameCannotBeEmpty', {}, 'error');
exitEditMode();
return;
}
@@ -415,7 +403,7 @@ export function setupFileNameEditing(filePath) {
} catch (error) {
console.error('Error renaming file:', error);
this.textContent = originalValue; // Restore original file name
showToast(`Failed to rename file: ${error.message}`, 'error');
showToast('toast.models.renameFailed', { message: error.message }, 'error');
} finally {
exitEditMode();
}

View File

@@ -6,7 +6,7 @@ import {
scrollToTop,
loadExampleImages
} from './showcase/ShowcaseView.js';
import { setupTabSwitching, setupModelDescriptionEditing } from './ModelDescription.js';
import { setupTabSwitching } from './ModelDescription.js';
import {
setupModelNameEditing,
setupBaseModelEditing,
@@ -18,74 +18,100 @@ import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
import { parsePresets, renderPresetTags } from './PresetTags.js';
import { loadRecipesForLora } from './RecipeTab.js';
import { translate } from '../../utils/i18nHelpers.js';
/**
* Display the model modal with the given model data
* @param {Object} model - Model data object
* @param {string} modelType - Type of model ('lora' or 'checkpoint')
*/
export function showModelModal(model, modelType) {
export async function showModelModal(model, modelType) {
const modalId = 'modelModal';
const modalTitle = model.model_name;
// Fetch complete civitai metadata
let completeCivitaiData = model.civitai || {};
if (model.file_path) {
try {
const fullMetadata = await getModelApiClient().fetchModelMetadata(model.file_path);
completeCivitaiData = fullMetadata || model.civitai || {};
} catch (error) {
console.warn('Failed to fetch complete metadata, using existing data:', error);
// Continue with existing data if fetch fails
}
}
// Prepare LoRA specific data
const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && model.civitai?.trainedWords?.length ?
model.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
// Update model with complete civitai data
const modelWithFullData = {
...model,
civitai: completeCivitaiData
};
// Prepare LoRA specific data with complete civitai data
const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ?
modelWithFullData.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
// Generate model type specific content
let typeSpecificContent;
if (modelType === 'loras') {
typeSpecificContent = renderLoraSpecificContent(model, escapedWords);
typeSpecificContent = renderLoraSpecificContent(modelWithFullData, escapedWords);
} else if (modelType === 'embeddings') {
typeSpecificContent = renderEmbeddingSpecificContent(model, escapedWords);
typeSpecificContent = renderEmbeddingSpecificContent(modelWithFullData, escapedWords);
} else {
typeSpecificContent = '';
}
// Generate tabs based on model type
const examplesText = translate('modals.model.tabs.examples', {}, 'Examples');
const descriptionText = translate('modals.model.tabs.description', {}, 'Model Description');
const recipesText = translate('modals.model.tabs.recipes', {}, 'Recipes');
const tabsContent = modelType === 'loras' ?
`<button class="tab-btn active" data-tab="showcase">Examples</button>
<button class="tab-btn" data-tab="description">Model Description</button>
<button class="tab-btn" data-tab="recipes">Recipes</button>` :
`<button class="tab-btn active" data-tab="showcase">Examples</button>
<button class="tab-btn" data-tab="description">Model Description</button>`;
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
<button class="tab-btn" data-tab="description">${descriptionText}</button>
<button class="tab-btn" data-tab="recipes">${recipesText}</button>` :
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
<button class="tab-btn" data-tab="description">${descriptionText}</button>`;
const loadingExampleImagesText = translate('modals.model.loading.exampleImages', {}, 'Loading example images...');
const loadingDescriptionText = translate('modals.model.loading.description', {}, 'Loading model description...');
const loadingRecipesText = translate('modals.model.loading.recipes', {}, 'Loading recipes...');
const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...');
const tabPanesContent = modelType === 'loras' ?
`<div id="showcase-tab" class="tab-pane active">
<div class="example-images-loading">
<i class="fas fa-spinner fa-spin"></i> Loading example images...
<i class="fas fa-spinner fa-spin"></i> ${loadingExampleImagesText}
</div>
</div>
<div id="description-tab" class="tab-pane">
<div class="model-description-container">
<div class="model-description-loading">
<i class="fas fa-spinner fa-spin"></i> Loading model description...
<i class="fas fa-spinner fa-spin"></i> ${loadingDescriptionText}
</div>
<div class="model-description-content">
${model.modelDescription || ''}
<div class="model-description-content hidden">
</div>
</div>
</div>
<div id="recipes-tab" class="tab-pane">
<div class="recipes-loading">
<i class="fas fa-spinner fa-spin"></i> Loading recipes...
<i class="fas fa-spinner fa-spin"></i> ${loadingRecipesText}
</div>
</div>` :
`<div id="showcase-tab" class="tab-pane active">
<div class="recipes-loading">
<i class="fas fa-spinner fa-spin"></i> Loading examples...
<i class="fas fa-spinner fa-spin"></i> ${loadingExamplesText}
</div>
</div>
<div id="description-tab" class="tab-pane">
<div class="model-description-container">
<div class="model-description-loading">
<i class="fas fa-spinner fa-spin"></i> Loading model description...
<i class="fas fa-spinner fa-spin"></i> ${loadingDescriptionText}
</div>
<div class="model-description-content">
${model.modelDescription || ''}
<div class="model-description-content hidden">
</div>
</div>
</div>`;
@@ -96,86 +122,86 @@ export function showModelModal(model, modelType) {
<header class="modal-header">
<div class="model-name-header">
<h2 class="model-name-content">${modalTitle}</h2>
<button class="edit-model-name-btn" title="Edit model name">
<button class="edit-model-name-btn" title="${translate('modals.model.actions.editModelName', {}, 'Edit model name')}">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
<div class="creator-actions">
${model.from_civitai ? `
<div class="civitai-view" title="View on Civitai" data-action="view-civitai" data-filepath="${model.file_path}">
<i class="fas fa-globe"></i> View on Civitai
${modelWithFullData.from_civitai ? `
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${modelWithFullData.file_path}">
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
</div>` : ''}
${model.civitai?.creator ? `
<div class="creator-info" data-username="${model.civitai.creator.username}" data-action="view-creator" title="View Creator Profile">
${model.civitai.creator.image ?
${modelWithFullData.civitai?.creator ? `
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
${modelWithFullData.civitai.creator.image ?
`<div class="creator-avatar">
<img src="${model.civitai.creator.image}" alt="${model.civitai.creator.username}" onerror="this.onerror=null; this.src='static/icons/user-placeholder.png';">
<img src="${modelWithFullData.civitai.creator.image}" alt="${modelWithFullData.civitai.creator.username}" onerror="this.onerror=null; this.src='static/icons/user-placeholder.png';">
</div>` :
`<div class="creator-avatar creator-placeholder">
<i class="fas fa-user"></i>
</div>`
}
<span class="creator-username">${model.civitai.creator.username}</span>
<span class="creator-username">${modelWithFullData.civitai.creator.username}</span>
</div>` : ''}
</div>
${renderCompactTags(model.tags || [], model.file_path)}
${renderCompactTags(modelWithFullData.tags || [], modelWithFullData.file_path)}
</header>
<div class="modal-body">
<div class="info-section">
<div class="info-grid">
<div class="info-item">
<label>Version</label>
<span>${model.civitai?.name || 'N/A'}</span>
<label>${translate('modals.model.metadata.version', {}, 'Version')}</label>
<span>${modelWithFullData.civitai?.name || 'N/A'}</span>
</div>
<div class="info-item">
<label>File Name</label>
<label>${translate('modals.model.metadata.fileName', {}, 'File Name')}</label>
<div class="file-name-wrapper">
<span id="file-name" class="file-name-content">${model.file_name || 'N/A'}</span>
<button class="edit-file-name-btn" title="Edit file name">
<span id="file-name" class="file-name-content">${modelWithFullData.file_name || 'N/A'}</span>
<button class="edit-file-name-btn" title="${translate('modals.model.actions.editFileName', {}, 'Edit file name')}">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
</div>
<div class="info-item location-size">
<div class="location-wrapper">
<label>Location</label>
<span class="file-path">${model.file_path.replace(/[^/]+$/, '') || 'N/A'}</span>
<label>${translate('modals.model.metadata.location', {}, 'Location')}</label>
<span class="file-path">${modelWithFullData.file_path.replace(/[^/]+$/, '') || 'N/A'}</span>
</div>
</div>
<div class="info-item base-size">
<div class="base-wrapper">
<label>Base Model</label>
<label>${translate('modals.model.metadata.baseModel', {}, 'Base Model')}</label>
<div class="base-model-display">
<span class="base-model-content">${model.base_model || 'Unknown'}</span>
<button class="edit-base-model-btn" title="Edit base model">
<span class="base-model-content">${modelWithFullData.base_model || translate('modals.model.metadata.unknown', {}, 'Unknown')}</span>
<button class="edit-base-model-btn" title="${translate('modals.model.actions.editBaseModel', {}, 'Edit base model')}">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
</div>
<div class="size-wrapper">
<label>Size</label>
<span>${formatFileSize(model.file_size)}</span>
<label>${translate('modals.model.metadata.size', {}, 'Size')}</label>
<span>${formatFileSize(modelWithFullData.file_size)}</span>
</div>
</div>
${typeSpecificContent}
<div class="info-item notes">
<label>Additional Notes <i class="fas fa-info-circle notes-hint" title="Press Enter to save, Shift+Enter for new line"></i></label>
<label>${translate('modals.model.metadata.additionalNotes', {}, 'Additional Notes')} <i class="fas fa-info-circle notes-hint" title="${translate('modals.model.metadata.notesHint', {}, 'Press Enter to save, Shift+Enter for new line')}"></i></label>
<div class="editable-field">
<div class="notes-content" contenteditable="true" spellcheck="false">${model.notes || 'Add your notes here...'}</div>
<div class="notes-content" contenteditable="true" spellcheck="false">${modelWithFullData.notes || translate('modals.model.metadata.addNotesPlaceholder', {}, 'Add your notes here...')}</div>
</div>
</div>
<div class="info-item full-width">
<label>About this version</label>
<div class="description-text">${model.civitai?.description || 'N/A'}</div>
<label>${translate('modals.model.metadata.aboutThisVersion', {}, 'About this version')}</label>
<div class="description-text">${modelWithFullData.civitai?.description || 'N/A'}</div>
</div>
</div>
</div>
<div class="showcase-section" data-model-hash="${model.sha256 || ''}" data-filepath="${model.file_path}">
<div class="showcase-section" data-model-hash="${modelWithFullData.sha256 || ''}" data-filepath="${modelWithFullData.file_path}">
<div class="showcase-tabs">
${tabsContent}
</div>
@@ -202,16 +228,15 @@ export function showModelModal(model, modelType) {
};
modalManager.showModal(modalId, content, null, onCloseCallback);
setupEditableFields(model.file_path, modelType);
setupEditableFields(modelWithFullData.file_path, modelType);
setupShowcaseScroll(modalId);
setupTabSwitching();
setupTagTooltip();
setupTagEditMode();
setupModelNameEditing(model.file_path);
setupBaseModelEditing(model.file_path);
setupFileNameEditing(model.file_path);
setupModelDescriptionEditing(model.file_path, model.modelDescription || '');
setupEventHandlers(model.file_path);
setupModelNameEditing(modelWithFullData.file_path);
setupBaseModelEditing(modelWithFullData.file_path);
setupFileNameEditing(modelWithFullData.file_path);
setupEventHandlers(modelWithFullData.file_path);
// LoRA specific setup
if (modelType === 'loras' || modelType === 'embeddings') {
@@ -219,33 +244,33 @@ export function showModelModal(model, modelType) {
if (modelType == 'loras') {
// Load recipes for this LoRA
loadRecipesForLora(model.model_name, model.sha256);
loadRecipesForLora(modelWithFullData.model_name, modelWithFullData.sha256);
}
}
// Load example images asynchronously - merge regular and custom images
const regularImages = model.civitai?.images || [];
const customImages = model.civitai?.customImages || [];
const regularImages = modelWithFullData.civitai?.images || [];
const customImages = modelWithFullData.civitai?.customImages || [];
// Combine images - regular images first, then custom images
const allImages = [...regularImages, ...customImages];
loadExampleImages(allImages, model.sha256);
loadExampleImages(allImages, modelWithFullData.sha256);
}
function renderLoraSpecificContent(lora, escapedWords) {
return `
<div class="info-item usage-tips">
<label>Usage Tips</label>
<label>${translate('modals.model.metadata.usageTips', {}, 'Usage Tips')}</label>
<div class="editable-field">
<div class="preset-controls">
<select id="preset-selector">
<option value="">Add preset parameter...</option>
<option value="strength_min">Strength Min</option>
<option value="strength_max">Strength Max</option>
<option value="strength">Strength</option>
<option value="clip_skip">Clip Skip</option>
<option value="">${translate('modals.model.usageTips.addPresetParameter', {}, 'Add preset parameter...')}</option>
<option value="strength_min">${translate('modals.model.usageTips.strengthMin', {}, 'Strength Min')}</option>
<option value="strength_max">${translate('modals.model.usageTips.strengthMax', {}, 'Strength Max')}</option>
<option value="strength">${translate('modals.model.usageTips.strength', {}, 'Strength')}</option>
<option value="clip_skip">${translate('modals.model.usageTips.clipSkip', {}, 'Clip Skip')}</option>
</select>
<input type="number" id="preset-value" step="0.01" placeholder="Value" style="display:none;">
<button class="add-preset-btn">Add</button>
<input type="number" id="preset-value" step="0.01" placeholder="${translate('modals.model.usageTips.valuePlaceholder', {}, 'Value')}" style="display:none;">
<button class="add-preset-btn">${translate('modals.model.usageTips.add', {}, 'Add')}</button>
</div>
<div class="preset-tags">
${renderPresetTags(parsePresets(lora.usage_tips))}
@@ -413,9 +438,9 @@ async function saveNotes(filePath) {
try {
await getModelApiClient().saveModelMetadata(filePath, { notes: content });
showToast('Notes saved successfully', 'success');
showToast('modals.model.notes.saved', {}, 'success');
} catch (error) {
showToast('Failed to save notes', 'error');
showToast('modals.model.notes.saveFailed', {}, 'error');
}
}

View File

@@ -4,13 +4,7 @@
*/
import { showToast } from '../../utils/uiHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
// Preset tag suggestions
const PRESET_TAGS = [
'character', 'style', 'concept', 'clothing',
'poses', 'background', 'vehicle', 'buildings',
'objects', 'animal'
];
import { PRESET_TAGS } from '../../utils/constants.js';
// Create a named function so we can remove it later
let saveTagsHandler = null;
@@ -138,7 +132,7 @@ export function setupTagEditMode() {
// ...existing helper functions...
/**
* Save tags - 支持LoRA和Checkpoint
* Save tags
*/
async function saveTags() {
const editBtn = document.querySelector('.edit-tags-btn');
@@ -216,10 +210,10 @@ async function saveTags() {
// Exit edit mode
editBtn.click();
showToast('Tags updated successfully', 'success');
showToast('modelTags.messages.updated', {}, 'success');
} catch (error) {
console.error('Error saving tags:', error);
showToast('Failed to update tags', 'error');
showToast('modelTags.messages.updateFailed', {}, 'error');
}
}
@@ -361,21 +355,21 @@ function addNewTag(tag) {
// Validation: Check length
if (tag.length > 30) {
showToast('Tag should not exceed 30 characters', 'error');
showToast('modelTags.validation.maxLength', {}, 'error');
return;
}
// Validation: Check total number
const currentTags = tagsContainer.querySelectorAll('.metadata-item');
if (currentTags.length >= 30) {
showToast('Maximum 30 tags allowed', 'error');
showToast('modelTags.validation.maxCount', {}, 'error');
return;
}
// Validation: Check for duplicates
const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag);
if (existingTags.includes(tag)) {
showToast('This tag already exists', 'error');
showToast('modelTags.validation.duplicate', {}, 'error');
return;
}

View File

@@ -162,7 +162,7 @@ function getLoraStatusTitle(totalCount, missingCount) {
*/
function copyRecipeSyntax(recipeId) {
if (!recipeId) {
showToast('Cannot copy recipe syntax: Missing recipe ID', 'error');
showToast('toast.recipes.noRecipeId', {}, 'error');
return;
}
@@ -177,7 +177,7 @@ function copyRecipeSyntax(recipeId) {
})
.catch(err => {
console.error('Failed to copy: ', err);
showToast('Failed to copy recipe syntax', 'error');
showToast('toast.recipes.copyFailed', { message: err.message }, 'error');
});
}

View File

@@ -4,6 +4,7 @@
* Moved to shared directory for consistency
*/
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { translate } from '../../utils/i18nHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
/**
@@ -26,7 +27,7 @@ async function fetchTrainedWords(filePath) {
}
} catch (error) {
console.error('Error fetching trained words:', error);
showToast('Could not load trained words', 'error');
showToast('toast.triggerWords.loadFailed', {}, 'error');
return { trainedWords: [], classTokens: null };
}
}
@@ -48,9 +49,9 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
// No suggestions case
if ((!trainedWords || trainedWords.length === 0) && !classTokens) {
header.innerHTML = '<span>No suggestions available</span>';
header.innerHTML = `<span>${translate('modals.model.triggerWords.suggestions.noSuggestions')}</span>`;
dropdown.appendChild(header);
dropdown.innerHTML += '<div class="no-suggestions">No trained words or class tokens found in this model. You can manually enter trigger words.</div>';
dropdown.innerHTML += `<div class="no-suggestions">${translate('modals.model.triggerWords.suggestions.noTrainedWords')}</div>`;
return dropdown;
}
@@ -65,8 +66,8 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
const classTokensHeader = document.createElement('div');
classTokensHeader.className = 'metadata-suggestions-header';
classTokensHeader.innerHTML = `
<span>Class Token</span>
<small>Add to your prompt for best results</small>
<span>${translate('modals.model.triggerWords.suggestions.classToken')}</span>
<small>${translate('modals.model.triggerWords.suggestions.classTokenDescription')}</small>
`;
dropdown.appendChild(classTokensHeader);
@@ -77,13 +78,13 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
// Create a special item for the class token
const tokenItem = document.createElement('div');
tokenItem.className = `metadata-suggestion-item class-token-item ${existingWords.includes(classTokens) ? 'already-added' : ''}`;
tokenItem.title = `Class token: ${classTokens}`;
tokenItem.title = `${translate('modals.model.triggerWords.suggestions.classToken')}: ${classTokens}`;
tokenItem.innerHTML = `
<span class="metadata-suggestion-text">${classTokens}</span>
<div class="metadata-suggestion-meta">
<span class="token-badge">Class Token</span>
<span class="token-badge">${translate('modals.model.triggerWords.suggestions.classToken')}</span>
${existingWords.includes(classTokens) ?
'<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
`<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''}
</div>
`;
@@ -119,8 +120,8 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
// Add trained words header if we have any
if (trainedWords && trainedWords.length > 0) {
header.innerHTML = `
<span>Word Suggestions</span>
<small>${trainedWords.length} words found</small>
<span>${translate('modals.model.triggerWords.suggestions.wordSuggestions')}</span>
<small>${translate('modals.model.triggerWords.suggestions.wordsFound', { count: trainedWords.length })}</small>
`;
dropdown.appendChild(header);
@@ -139,7 +140,7 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
<span class="metadata-suggestion-text">${word}</span>
<div class="metadata-suggestion-meta">
<span class="trained-word-freq">${frequency}</span>
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
${isAdded ? `<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''}
</div>
`;
@@ -166,7 +167,7 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
dropdown.appendChild(container);
} else if (!classTokens) {
// If we have neither class tokens nor trained words
dropdown.innerHTML += '<div class="no-suggestions">No word suggestions found in this model. You can manually enter trigger words.</div>';
dropdown.innerHTML += `<div class="no-suggestions">${translate('modals.model.triggerWords.suggestions.noTrainedWords')}</div>`;
}
return dropdown;
@@ -182,22 +183,22 @@ export function renderTriggerWords(words, filePath) {
if (!words.length) return `
<div class="info-item full-width trigger-words">
<div class="trigger-words-header">
<label>Trigger Words</label>
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="Edit trigger words">
<label>${translate('modals.model.triggerWords.label')}</label>
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="${translate('modals.model.triggerWords.edit')}">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
<div class="trigger-words-content">
<span class="no-trigger-words">No trigger word needed</span>
<span class="no-trigger-words">${translate('modals.model.triggerWords.noTriggerWordsNeeded')}</span>
<div class="trigger-words-tags" style="display:none;"></div>
</div>
<div class="metadata-edit-controls" style="display:none;">
<button class="metadata-save-btn" title="Save changes">
<i class="fas fa-save"></i> Save
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
<i class="fas fa-save"></i> ${translate('common.actions.save')}
</button>
</div>
<div class="metadata-add-form" style="display:none;">
<input type="text" class="metadata-input" placeholder="Type to add or click suggestions below">
<input type="text" class="metadata-input" placeholder="${translate('modals.model.triggerWords.addPlaceholder')}">
</div>
</div>
`;
@@ -205,20 +206,20 @@ export function renderTriggerWords(words, filePath) {
return `
<div class="info-item full-width trigger-words">
<div class="trigger-words-header">
<label>Trigger Words</label>
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="Edit trigger words">
<label>${translate('modals.model.triggerWords.label')}</label>
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="${translate('modals.model.triggerWords.edit')}">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
<div class="trigger-words-content">
<div class="trigger-words-tags">
${words.map(word => `
<div class="trigger-word-tag" data-word="${word}" onclick="copyTriggerWord('${word}')">
<div class="trigger-word-tag" data-word="${word}" onclick="copyTriggerWord('${word}')" title="${translate('modals.model.triggerWords.copyWord')}">
<span class="trigger-word-content">${word}</span>
<span class="trigger-word-copy">
<i class="fas fa-copy"></i>
</span>
<button class="metadata-delete-btn" style="display:none;" onclick="event.stopPropagation();">
<button class="metadata-delete-btn" style="display:none;" onclick="event.stopPropagation();" title="${translate('modals.model.triggerWords.deleteWord')}">
<i class="fas fa-times"></i>
</button>
</div>
@@ -226,12 +227,12 @@ export function renderTriggerWords(words, filePath) {
</div>
</div>
<div class="metadata-edit-controls" style="display:none;">
<button class="metadata-save-btn" title="Save changes">
<i class="fas fa-save"></i> Save
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
<i class="fas fa-save"></i> ${translate('common.actions.save')}
</button>
</div>
<div class="metadata-add-form" style="display:none;">
<input type="text" class="metadata-input" placeholder="Type to add or click suggestions below">
<input type="text" class="metadata-input" placeholder="${translate('modals.model.triggerWords.addPlaceholder')}">
</div>
</div>
`;
@@ -265,7 +266,7 @@ export function setupTriggerWordsEditMode() {
if (isEditMode) {
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
this.title = "Cancel editing";
this.title = translate('modals.model.triggerWords.cancel');
// Store original trigger words for potential restoration
originalTriggerWords = Array.from(triggerWordTags).map(tag => tag.dataset.word);
@@ -302,7 +303,7 @@ export function setupTriggerWordsEditMode() {
// Add loading indicator
const loadingIndicator = document.createElement('div');
loadingIndicator.className = 'metadata-loading';
loadingIndicator.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading suggestions...';
loadingIndicator.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${translate('modals.model.triggerWords.suggestions.loading')}`;
addForm.appendChild(loadingIndicator);
// Get currently added trigger words
@@ -329,7 +330,7 @@ export function setupTriggerWordsEditMode() {
} else {
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
this.title = "Edit trigger words";
this.title = translate('modals.model.triggerWords.edit');
// Hide edit controls and input form
editControls.style.display = 'none';
@@ -499,21 +500,21 @@ function addNewTriggerWord(word) {
// Validation: Check length
if (word.split(/\s+/).length > 30) {
showToast('Trigger word should not exceed 30 words', 'error');
showToast('toast.triggerWords.tooLong', {}, 'error');
return;
}
// Validation: Check total number
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
if (currentTags.length >= 30) {
showToast('Maximum 30 trigger words allowed', 'error');
showToast('toast.triggerWords.tooMany', {}, 'error');
return;
}
// Validation: Check for duplicates
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
if (existingWords.includes(word)) {
showToast('This trigger word already exists', 'error');
showToast('toast.triggerWords.alreadyExists', {}, 'error');
return;
}
@@ -628,10 +629,10 @@ async function saveTriggerWords() {
if (tagsContainer) tagsContainer.style.display = 'none';
}
showToast('Trigger words updated successfully', 'success');
showToast('toast.triggerWords.updateSuccess', {}, 'success');
} catch (error) {
console.error('Error saving trigger words:', error);
showToast('Failed to update trigger words', 'error');
showToast('toast.triggerWords.updateFailed', {}, 'error');
}
}
@@ -644,6 +645,6 @@ window.copyTriggerWord = async function(word) {
await copyToClipboard(word, 'Trigger word copied');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
showToast('toast.triggerWords.copyFailed', {}, 'error');
}
};

View File

@@ -278,7 +278,7 @@ export function initMetadataPanelHandlers(container) {
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
showToast('toast.triggerWords.copyFailed', {}, 'error');
}
});
});
@@ -432,7 +432,7 @@ export function initMediaControlHandlers(container) {
}, 600);
// Show success toast
showToast('Example image deleted', 'success');
showToast('toast.exampleImages.deleted', {}, 'success');
// Create an update object with only the necessary properties
const updateData = {
@@ -445,7 +445,7 @@ export function initMediaControlHandlers(container) {
state.virtualScroller.updateSingleItem(result.model_file_path, updateData);
} else {
// Show error message
showToast(result.error || 'Failed to delete example image', 'error');
showToast('toast.exampleImages.deleteFailed', { error: result.error }, 'error');
// Reset button state
this.disabled = false;
@@ -456,7 +456,7 @@ export function initMediaControlHandlers(container) {
}
} catch (error) {
console.error('Error deleting example image:', error);
showToast('Failed to delete example image', 'error');
showToast('toast.exampleImages.deleteFailed', {}, 'error');
// Reset button state
this.disabled = false;
@@ -536,7 +536,7 @@ function initSetPreviewHandlers(container) {
}
} catch (error) {
console.error('Error setting preview:', error);
showToast('Failed to set preview image', 'error');
showToast('toast.exampleImages.setPreviewFailed', {}, 'error');
} finally {
// Restore button state
this.innerHTML = '<i class="fas fa-image"></i>';

View File

@@ -412,7 +412,7 @@ async function handleImportFiles(files, modelHash, importContainer) {
// Initialize the import UI for the new content
initExampleImport(modelHash, showcaseTab);
showToast('Example images imported successfully', 'success');
showToast('toast.import.imagesImported', {}, 'success');
// Update VirtualScroller if available
if (state.virtualScroller && result.model_file_path) {
@@ -430,7 +430,7 @@ async function handleImportFiles(files, modelHash, importContainer) {
}
} catch (error) {
console.error('Error importing examples:', error);
showToast(`Failed to import example images: ${error.message}`, 'error');
showToast('toast.import.importFailed', { message: error.message }, 'error');
}
}

View File

@@ -10,9 +10,14 @@ import { bulkManager } from './managers/BulkManager.js';
import { exampleImagesManager } from './managers/ExampleImagesManager.js';
import { helpManager } from './managers/HelpManager.js';
import { bannerService } from './managers/BannerService.js';
import { showToast, initTheme, initBackToTop } from './utils/uiHelpers.js';
import { initTheme, initBackToTop } from './utils/uiHelpers.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { migrateStorageItems } from './utils/storageHelpers.js';
import { i18n } from './i18n/index.js';
import { onboardingManager } from './managers/OnboardingManager.js';
import { BulkContextMenu } from './components/ContextMenu/BulkContextMenu.js';
import { createPageContextMenu } from './components/ContextMenu/index.js';
import { initializeEventManagement } from './utils/eventManagementInit.js';
// Core application class
export class AppCore {
@@ -26,6 +31,13 @@ export class AppCore {
console.log('AppCore: Initializing...');
// Initialize i18n first
window.i18n = i18n;
// Wait for i18n to be ready
await window.i18n.waitForReady();
console.log(`AppCore: Language set: ${i18n.getCurrentLocale()}`);
// Initialize managers
state.loadingManager = new LoadingManager();
modalManager.initialize();
@@ -46,6 +58,10 @@ export class AppCore {
// Initialize the bulk manager
bulkManager.initialize();
// Initialize bulk context menu
const bulkContextMenu = new BulkContextMenu();
bulkManager.setBulkContextMenu(bulkContextMenu);
// Initialize the example images manager
exampleImagesManager.initialize();
// Initialize the help manager
@@ -53,10 +69,20 @@ export class AppCore {
const cardInfoDisplay = state.global.settings.cardInfoDisplay || 'always';
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
initializeEventManagement();
// Mark as initialized
this.initialized = true;
// Start onboarding if needed (after everything is initialized)
setTimeout(() => {
// Do not show onboarding if version-mismatch banner is visible
if (!bannerService.isBannerVisible('version-mismatch')) {
onboardingManager.start();
}
}, 1000); // Small delay to ensure all elements are rendered
// Return the core instance for chaining
return this;
}
@@ -67,22 +93,23 @@ export class AppCore {
return body.dataset.page || 'unknown';
}
// Show toast messages
showToast(message, type = 'info') {
showToast(message, type);
}
// Initialize common UI features based on page type
initializePageFeatures() {
const pageType = this.getPageType();
// Initialize virtual scroll for pages that need it
if (['loras', 'recipes', 'checkpoints', 'embeddings'].includes(pageType)) {
this.initializeContextMenus(pageType);
initializeInfiniteScroll(pageType);
}
return this;
}
// Initialize context menus for the current page
initializeContextMenus(pageType) {
// Create page-specific context menu
window.pageContextMenu = createPageContextMenu(pageType);
}
}
document.addEventListener('DOMContentLoaded', () => {

View File

@@ -1,7 +1,6 @@
import { appCore } from './core.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { createPageControls } from './components/controls/index.js';
import { EmbeddingContextMenu } from './components/ContextMenu/index.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { MODEL_TYPES } from './api/apiConfig.js';
@@ -30,14 +29,7 @@ class EmbeddingsPageManager {
}
async initialize() {
// Initialize page-specific components
this.pageControls.restoreFolderFilter();
this.pageControls.initFolderTagsVisibility();
// Initialize context menu
new EmbeddingContextMenu();
// Initialize common page features
// Initialize common page features (including context menus)
appCore.initializePageFeatures();
console.log('Embeddings Manager initialized');

341
static/js/i18n/index.js Normal file
View File

@@ -0,0 +1,341 @@
/**
* Internationalization (i18n) system for LoRA Manager
* Uses user-selected language from settings with fallback to English
* Loads JSON translation files dynamically
*/
class I18nManager {
constructor() {
this.locales = {};
this.translations = {};
this.loadedLocales = new Set();
this.ready = false;
this.readyPromise = null;
// Available locales configuration
this.availableLocales = {
'en': { name: 'English', nativeName: 'English' },
'zh-CN': { name: 'Chinese (Simplified)', nativeName: '简体中文' },
'zh-TW': { name: 'Chinese (Traditional)', nativeName: '繁體中文' },
'zh': { name: 'Chinese (Simplified)', nativeName: '简体中文' }, // Fallback to zh-CN
'ru': { name: 'Russian', nativeName: 'Русский' },
'de': { name: 'German', nativeName: 'Deutsch' },
'ja': { name: 'Japanese', nativeName: '日本語' },
'ko': { name: 'Korean', nativeName: '한국어' },
'fr': { name: 'French', nativeName: 'Français' },
'es': { name: 'Spanish', nativeName: 'Español' }
};
this.currentLocale = this.getLanguageFromSettings();
// Initialize with current locale and create ready promise
this.readyPromise = this.initializeWithLocale(this.currentLocale);
}
/**
* Load translations for a specific locale from JSON file
* @param {string} locale - The locale to load
* @returns {Promise<Object>} Promise that resolves to the translation data
*/
async loadLocale(locale) {
// Handle fallback for 'zh' to 'zh-CN'
const normalizedLocale = locale === 'zh' ? 'zh-CN' : locale;
if (this.loadedLocales.has(normalizedLocale)) {
return this.locales[normalizedLocale];
}
try {
const response = await fetch(`/locales/${normalizedLocale}.json`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const translations = await response.json();
this.locales[normalizedLocale] = translations;
this.loadedLocales.add(normalizedLocale);
// Also set for 'zh' alias
if (normalizedLocale === 'zh-CN') {
this.locales['zh'] = translations;
this.loadedLocales.add('zh');
}
return translations;
} catch (error) {
console.warn(`Failed to load locale ${normalizedLocale}:`, error);
// Fallback to English if current locale fails and it's not English
if (normalizedLocale !== 'en') {
return this.loadLocale('en');
}
// Return empty object if even English fails
return {};
}
}
/**
* Initialize with a specific locale
* @param {string} locale - The locale to initialize with
*/
async initializeWithLocale(locale) {
try {
this.translations = await this.loadLocale(locale);
this.currentLocale = locale;
this.ready = true;
// Dispatch ready event
window.dispatchEvent(new CustomEvent('i18nReady', {
detail: { language: locale }
}));
} catch (error) {
console.warn(`Failed to initialize with locale ${locale}, falling back to English`, error);
this.translations = await this.loadLocale('en');
this.currentLocale = 'en';
this.ready = true;
window.dispatchEvent(new CustomEvent('i18nReady', {
detail: { language: 'en' }
}));
}
}
/**
* Wait for i18n to be ready
* @returns {Promise} Promise that resolves when i18n is ready
*/
async waitForReady() {
if (this.ready) {
return Promise.resolve();
}
return this.readyPromise;
}
/**
* Check if i18n is ready
* @returns {boolean} True if ready
*/
isReady() {
return this.ready && this.translations && Object.keys(this.translations).length > 0;
}
/**
* Get language from user settings with fallback to English
* @returns {string} Language code
*/
getLanguageFromSettings() {
// Check localStorage for user-selected language
const STORAGE_PREFIX = 'lora_manager_';
let userLanguage = null;
try {
const settings = localStorage.getItem(STORAGE_PREFIX + 'settings');
if (settings) {
const parsedSettings = JSON.parse(settings);
userLanguage = parsedSettings.language;
}
} catch (e) {
console.warn('Failed to parse settings from localStorage:', e);
}
// If user has selected a language, use it
if (userLanguage && this.availableLocales[userLanguage]) {
return userLanguage;
}
// Fallback to English
return 'en';
}
/**
* Set the current language and save to settings
* @param {string} languageCode - The language code to set
* @returns {Promise<boolean>} True if language was successfully set
*/
async setLanguage(languageCode) {
if (!this.availableLocales[languageCode]) {
console.warn(`Language '${languageCode}' is not supported`);
return false;
}
try {
// Reset ready state
this.ready = false;
// Load the new locale
this.readyPromise = this.initializeWithLocale(languageCode);
await this.readyPromise;
// Save to localStorage
const STORAGE_PREFIX = 'lora_manager_';
const currentSettings = localStorage.getItem(STORAGE_PREFIX + 'settings');
let settings = {};
if (currentSettings) {
settings = JSON.parse(currentSettings);
}
settings.language = languageCode;
localStorage.setItem(STORAGE_PREFIX + 'settings', JSON.stringify(settings));
console.log(`Language changed to: ${languageCode}`);
// Dispatch event to notify components of language change
window.dispatchEvent(new CustomEvent('languageChanged', {
detail: { language: languageCode }
}));
return true;
} catch (e) {
console.error('Failed to set language:', e);
return false;
}
}
/**
* Get list of available languages with their native names
* @returns {Array} Array of language objects
*/
getAvailableLanguages() {
return Object.entries(this.availableLocales).map(([code, info]) => ({
code,
name: info.name,
nativeName: info.nativeName
}));
}
/**
* Get translation for a key with optional parameters
* @param {string} key - Translation key (supports dot notation)
* @param {Object} params - Parameters for string interpolation
* @returns {string} Translated text
*/
t(key, params = {}) {
// If not ready, return key as fallback
if (!this.isReady()) {
console.warn(`i18n not ready, returning key: ${key}`);
return key;
}
const keys = key.split('.');
let value = this.translations;
// Navigate through nested object
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
// Fallback to English if key not found in current locale
if (this.currentLocale !== 'en' && this.locales['en']) {
let fallbackValue = this.locales['en'];
for (const fallbackKey of keys) {
if (fallbackValue && typeof fallbackValue === 'object' && fallbackKey in fallbackValue) {
fallbackValue = fallbackValue[fallbackKey];
} else {
console.warn(`Translation key not found: ${key}`);
return key; // Return key as fallback
}
}
value = fallbackValue;
} else {
console.warn(`Translation key not found: ${key}`);
return key; // Return key as fallback
}
break;
}
}
if (typeof value !== 'string') {
console.warn(`Translation key is not a string: ${key}`);
return key;
}
// Replace parameters in the string
return this.interpolate(value, params);
}
/**
* Interpolate parameters into a string
* Supports both {{param}} and {param} syntax
* @param {string} str - String with placeholders
* @param {Object} params - Parameters to interpolate
* @returns {string} Interpolated string
*/
interpolate(str, params) {
return str.replace(/\{\{?(\w+)\}?\}/g, (match, key) => {
return params[key] !== undefined ? params[key] : match;
});
}
/**
* Get current locale
* @returns {string} Current locale code
*/
getCurrentLocale() {
return this.currentLocale;
}
/**
* Check if current locale is RTL (Right-to-Left)
* @returns {boolean} True if RTL
*/
isRTL() {
const rtlLocales = ['ar', 'he', 'fa', 'ur'];
return rtlLocales.includes(this.currentLocale.split('-')[0]);
}
/**
* Format number according to current locale
* @param {number} number - Number to format
* @param {Object} options - Intl.NumberFormat options
* @returns {string} Formatted number
*/
formatNumber(number, options = {}) {
return new Intl.NumberFormat(this.currentLocale, options).format(number);
}
/**
* Format date according to current locale
* @param {Date|string|number} date - Date to format
* @param {Object} options - Intl.DateTimeFormat options
* @returns {string} Formatted date
*/
formatDate(date, options = {}) {
const dateObj = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat(this.currentLocale, options).format(dateObj);
}
/**
* Format file size with locale-specific formatting
* @param {number} bytes - Size in bytes
* @param {number} decimals - Number of decimal places
* @returns {string} Formatted size
*/
formatFileSize(bytes, decimals = 2) {
if (bytes === 0) return this.t('common.fileSize.zero');
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['bytes', 'kb', 'mb', 'gb', 'tb'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const size = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
return `${this.formatNumber(size)} ${this.t(`common.fileSize.${sizes[i]}`)}`;
}
/**
* Initialize i18n from user settings
* This prevents language flashing on page load
* @deprecated Use waitForReady() instead
*/
async initializeFromSettings() {
console.warn('initializeFromSettings() is deprecated, use waitForReady() instead');
return this.waitForReady();
}
}
// Create singleton instance
export const i18n = new I18nManager();
// Export for global access (will be attached to window)
export default i18n;

View File

@@ -1,7 +1,6 @@
import { appCore } from './core.js';
import { state } from './state/index.js';
import { updateCardsForBulkMode } from './components/shared/ModelCard.js';
import { LoraContextMenu } from './components/ContextMenu/index.js';
import { createPageControls } from './components/controls/index.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
@@ -37,15 +36,10 @@ class LoraPageManager {
}
async initialize() {
// Initialize page-specific components
this.pageControls.restoreFolderFilter();
this.pageControls.initFolderTagsVisibility();
new LoraContextMenu();
// Initialize cards for current bulk mode state (should be false initially)
updateCardsForBulkMode(state.bulkMode);
// Initialize common page features (virtual scroll)
// Initialize common page features (including context menus and virtual scroll)
appCore.initializePageFeatures();
}
}

View File

@@ -136,10 +136,10 @@ class BannerService {
const actionsHtml = banner.actions ? banner.actions.map(action => {
const actionAttribute = action.action ? `data-action="${action.action}"` : '';
const href = action.url ? `href="${action.url}"` : '#';
const href = action.url ? `href="${action.url}"` : 'href="#"';
const target = action.url ? 'target="_blank" rel="noopener noreferrer"' : '';
return `<a ${href ? `href="${href}"` : ''} ${target} class="banner-action banner-action-${action.type}" ${actionAttribute}>
return `<a ${href} ${target} class="banner-action banner-action-${action.type}" ${actionAttribute}>
<i class="${action.icon}"></i>
<span>${action.text}</span>
</a>`;
@@ -171,6 +171,16 @@ class BannerService {
}
}
/**
* Check if a banner is currently rendered and visible
* @param {string} bannerId
* @returns {boolean}
*/
isBannerVisible(bannerId) {
const el = document.querySelector(`[data-banner-id="${bannerId}"]`);
return !!el && el.offsetParent !== null;
}
/**
* Update container visibility based on active banners
*/

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import { LoadingManager } from './LoadingManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { translate } from '../utils/i18nHelpers.js';
export class DownloadManager {
constructor() {
@@ -85,26 +86,26 @@ export class DownloadManager {
const config = this.apiClient.apiConfig.config;
// Update modal title
document.getElementById('downloadModalTitle').textContent = `Download ${config.displayName} from URL`;
document.getElementById('downloadModalTitle').textContent = translate('modals.download.titleWithType', { type: config.displayName });
// Update URL label
document.getElementById('modelUrlLabel').textContent = 'Civitai URL:';
document.getElementById('modelUrlLabel').textContent = translate('modals.download.civitaiUrl');
// Update root selection label
document.getElementById('modelRootLabel').textContent = `Select ${config.displayName} Root:`;
document.getElementById('modelRootLabel').textContent = translate('modals.download.selectTypeRoot', { type: config.displayName });
// Update path preview labels
const pathLabels = document.querySelectorAll('.path-preview label');
pathLabels.forEach(label => {
if (label.textContent.includes('Location Preview')) {
label.textContent = 'Download Location Preview:';
label.textContent = translate('modals.download.locationPreview') + ':';
}
});
// Update initial path text
const pathText = document.querySelector('#targetPathDisplay .path-text');
if (pathText) {
pathText.textContent = `Select a ${config.displayName} root directory`;
pathText.textContent = translate('modals.download.selectTypeRoot', { type: config.displayName });
}
}
@@ -142,17 +143,17 @@ export class DownloadManager {
const errorElement = document.getElementById('urlError');
try {
this.loadingManager.showSimpleLoading('Fetching model versions...');
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
this.modelId = this.extractModelId(url);
if (!this.modelId) {
throw new Error('Invalid Civitai URL format');
throw new Error(translate('modals.download.errors.invalidUrl'));
}
this.versions = await this.apiClient.fetchCivitaiVersions(this.modelId);
if (!this.versions.length) {
throw new Error('No versions available for this model');
throw new Error(translate('modals.download.errors.noVersions'));
}
// If we have a version ID from URL, pre-select it
@@ -199,15 +200,15 @@ export class DownloadManager {
let earlyAccessBadge = '';
if (isEarlyAccess) {
earlyAccessBadge = `
<div class="early-access-badge" title="Early access required">
<i class="fas fa-clock"></i> Early Access
<div class="early-access-badge" title="${translate('modals.download.earlyAccessTooltip')}">
<i class="fas fa-clock"></i> ${translate('modals.download.earlyAccess')}
</div>
`;
}
const localStatus = existsLocally ?
`<div class="local-badge">
<i class="fas fa-check"></i> In Library
<i class="fas fa-check"></i> ${translate('modals.download.inLibrary')}
<div class="local-path">${localPath || ''}</div>
</div>` : '';
@@ -217,7 +218,7 @@ export class DownloadManager {
${isEarlyAccess ? 'is-early-access' : ''}"
data-version-id="${version.id}">
<div class="version-thumbnail">
<img src="${thumbnailUrl}" alt="Version preview">
<img src="${thumbnailUrl}" alt="${translate('modals.download.versionPreview')}">
</div>
<div class="version-content">
<div class="version-header">
@@ -273,23 +274,23 @@ export class DownloadManager {
if (existsLocally) {
nextButton.disabled = true;
nextButton.classList.add('disabled');
nextButton.textContent = 'Already in Library';
nextButton.textContent = translate('modals.download.alreadyInLibrary');
} else {
nextButton.disabled = false;
nextButton.classList.remove('disabled');
nextButton.textContent = 'Next';
nextButton.textContent = translate('common.actions.next');
}
}
async proceedToLocation() {
if (!this.currentVersion) {
showToast('Please select a version', 'error');
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
return;
}
const existsLocally = this.currentVersion.existsLocally;
if (existsLocally) {
showToast('This version already exists in your library', 'info');
showToast('toast.loras.versionExists', {}, 'info');
return;
}
@@ -343,7 +344,7 @@ export class DownloadManager {
this.updateTargetPath();
} catch (error) {
showToast(error.message, 'error');
showToast('toast.downloads.loadError', { message: error.message }, 'error');
}
}
@@ -418,7 +419,7 @@ export class DownloadManager {
const config = this.apiClient.apiConfig.config;
if (!modelRoot) {
showToast(`Please select a ${config.displayName} root directory`, 'error');
showToast('toast.models.pleaseSelectRoot', { type: config.displayName }, 'error');
return;
}
@@ -455,13 +456,13 @@ export class DownloadManager {
updateProgress(data.progress, 0, this.currentVersion.name);
if (data.progress < 3) {
this.loadingManager.setStatus(`Preparing download...`);
this.loadingManager.setStatus(translate('modals.download.status.preparing'));
} else if (data.progress === 3) {
this.loadingManager.setStatus(`Downloaded preview image`);
this.loadingManager.setStatus(translate('modals.download.status.downloadedPreview'));
} else if (data.progress > 3 && data.progress < 100) {
this.loadingManager.setStatus(`Downloading ${config.singularName} file`);
this.loadingManager.setStatus(translate('modals.download.status.downloadingFile', { type: config.singularName }));
} else {
this.loadingManager.setStatus(`Finalizing download...`);
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
}
}
};
@@ -480,7 +481,7 @@ export class DownloadManager {
downloadId
);
showToast('Download completed successfully', 'success');
showToast('toast.loras.downloadCompleted', {}, 'success');
modalManager.closeModal('downloadModal');
ws.close();
@@ -507,7 +508,7 @@ export class DownloadManager {
await resetAndReload(true);
} catch (error) {
showToast(error.message, 'error');
showToast('toast.downloads.downloadError', { message: error.message }, 'error');
} finally {
this.loadingManager.hide();
}
@@ -523,11 +524,11 @@ export class DownloadManager {
await this.folderTreeManager.loadTree(treeData.tree);
} else {
console.error('Failed to fetch folder tree:', treeData.error);
showToast('Failed to load folder tree', 'error');
showToast('toast.import.folderTreeFailed', {}, 'error');
}
} catch (error) {
console.error('Error initializing folder tree:', error);
showToast('Error loading folder tree', 'error');
showToast('toast.import.folderTreeError', {}, 'error');
}
}
@@ -586,7 +587,7 @@ export class DownloadManager {
const modelRoot = document.getElementById('modelRoot').value;
const config = this.apiClient.apiConfig.config;
let fullPath = modelRoot || `Select a ${config.displayName} root directory`;
let fullPath = modelRoot || translate('modals.download.selectTypeRoot', { type: config.displayName });
if (modelRoot) {
if (this.useDefaultPath) {
@@ -598,7 +599,7 @@ export class DownloadManager {
fullPath += `/${template}`;
} catch (error) {
console.error('Failed to fetch template:', error);
fullPath += '/[Auto-organized by path template]';
fullPath += '/' + translate('modals.download.autoOrganizedPath');
}
} else {
// Show manual path selection

View File

@@ -142,7 +142,7 @@ class ExampleImagesManager {
if (!data.success) {
console.error('Failed to update example images path in backend:', data.error);
} else {
showToast('Example images path updated successfully', 'success');
showToast('toast.exampleImages.pathUpdated', {}, 'success');
}
} catch (error) {
console.error('Failed to update example images path:', error);
@@ -187,7 +187,7 @@ class ExampleImagesManager {
this.startDownload();
} else {
// If download is in progress, show info toast
showToast('Download already in progress', 'info');
showToast('toast.exampleImages.downloadInProgress', {}, 'info');
}
}
@@ -243,7 +243,7 @@ class ExampleImagesManager {
async startDownload() {
if (this.isDownloading) {
showToast('Download already in progress', 'warning');
showToast('toast.exampleImages.downloadInProgress', {}, 'warning');
return;
}
@@ -251,7 +251,7 @@ class ExampleImagesManager {
const outputDir = document.getElementById('exampleImagesPath').value || '';
if (!outputDir) {
showToast('Please enter a download location first', 'warning');
showToast('toast.exampleImages.enterLocationFirst', {}, 'warning');
return;
}
@@ -280,16 +280,16 @@ class ExampleImagesManager {
this.showProgressPanel();
this.startProgressUpdates();
this.updateDownloadButtonText();
showToast('Example images download started', 'success');
showToast('toast.exampleImages.downloadStarted', {}, 'success');
// Close settings modal
modalManager.closeModal('settingsModal');
} else {
showToast(data.error || 'Failed to start download', 'error');
showToast('toast.exampleImages.downloadStartFailed', { error: data.error }, 'error');
}
} catch (error) {
console.error('Failed to start download:', error);
showToast('Failed to start download', 'error');
showToast('toast.exampleImages.downloadStartFailed', {}, 'error');
}
}
@@ -319,13 +319,13 @@ class ExampleImagesManager {
}
this.updateDownloadButtonText();
showToast('Download paused', 'info');
showToast('toast.exampleImages.downloadPaused', {}, 'info');
} else {
showToast(data.error || 'Failed to pause download', 'error');
showToast('toast.exampleImages.pauseFailed', { error: data.error }, 'error');
}
} catch (error) {
console.error('Failed to pause download:', error);
showToast('Failed to pause download', 'error');
showToast('toast.exampleImages.pauseFailed', {}, 'error');
}
}
@@ -355,13 +355,13 @@ class ExampleImagesManager {
}
this.updateDownloadButtonText();
showToast('Download resumed', 'success');
showToast('toast.exampleImages.downloadResumed', {}, 'success');
} else {
showToast(data.error || 'Failed to resume download', 'error');
showToast('toast.exampleImages.resumeFailed', { error: data.error }, 'error');
}
} catch (error) {
console.error('Failed to resume download:', error);
showToast('Failed to resume download', 'error');
showToast('toast.exampleImages.resumeFailed', {}, 'error');
}
}
@@ -399,7 +399,7 @@ class ExampleImagesManager {
if (data.status.status === 'completed' && !this.hasShownCompletionToast) {
const actionType = this.isMigrating ? 'migration' : 'download';
showToast(`Example images ${actionType} completed`, 'success');
showToast('toast.downloads.imagesCompleted', { action: actionType }, 'success');
// Mark as shown to prevent duplicate toasts
this.hasShownCompletionToast = true;
// Reset migration flag
@@ -408,7 +408,7 @@ class ExampleImagesManager {
setTimeout(() => this.hideProgressPanel(), 5000);
} else if (data.status.status === 'error') {
const actionType = this.isMigrating ? 'migration' : 'download';
showToast(`Example images ${actionType} failed`, 'error');
showToast('toast.downloads.imagesFailed', { action: actionType }, 'error');
this.isMigrating = false;
}
}

View File

@@ -282,12 +282,12 @@ export class FilterManager {
message = `Filtering by ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
}
showToast(message, 'success');
showToast('toast.filters.applied', { message }, 'success');
}
} else {
this.filterButton.classList.remove('active');
if (showToastNotification) {
showToast('Filters cleared', 'info');
showToast('toast.filters.cleared', {}, 'info');
}
}
}
@@ -321,7 +321,7 @@ export class FilterManager {
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
}
showToast(`Filters cleared`, 'info');
showToast('toast.filters.cleared', {}, 'info');
}
loadFiltersFromStorage() {

View File

@@ -11,6 +11,7 @@ import { getModelApiClient } from '../api/modelApiFactory.js';
import { state } from '../state/index.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
export class ImportManager {
constructor() {
@@ -110,7 +111,7 @@ export class ImportManager {
if (recipeName) recipeName.value = '';
const tagsContainer = document.getElementById('tagsContainer');
if (tagsContainer) tagsContainer.innerHTML = '<div class="empty-tags">No tags added</div>';
if (tagsContainer) tagsContainer.innerHTML = `<div class="empty-tags">${translate('recipes.controls.import.noTagsAdded', {}, 'No tags added')}</div>`;
// Clear folder path input
const folderPathInput = document.getElementById('importFolderPath');
@@ -261,7 +262,7 @@ export class ImportManager {
this.updateTargetPath();
} catch (error) {
showToast(error.message, 'error');
showToast('toast.recipes.importFailed', { message: error.message }, 'error');
}
}
@@ -350,11 +351,11 @@ export class ImportManager {
await this.folderTreeManager.loadTree(treeData.tree);
} else {
console.error('Failed to fetch folder tree:', treeData.error);
showToast('Failed to load folder tree', 'error');
showToast('toast.recipes.folderTreeFailed', {}, 'error');
}
} catch (error) {
console.error('Error initializing folder tree:', error);
showToast('Error loading folder tree', 'error');
showToast('toast.recipes.folderTreeError', {}, 'error');
}
}
@@ -368,9 +369,7 @@ export class ImportManager {
const pathDisplay = document.getElementById('importTargetPathDisplay');
const loraRoot = document.getElementById('importLoraRoot').value;
let fullPath = loraRoot || 'Select a LoRA root directory';
if (loraRoot) {
let fullPath = loraRoot || translate('recipes.controls.import.selectLoraRoot', {}, 'Select a LoRA root directory'); if (loraRoot) {
if (this.useDefaultPath) {
// Show actual template path
try {
@@ -425,11 +424,11 @@ export class ImportManager {
// Update the modal title
const modalTitle = document.querySelector('#importModal h2');
if (modalTitle) modalTitle.textContent = 'Download Missing LoRAs';
if (modalTitle) modalTitle.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs');
// Update the save button text
const saveButton = document.querySelector('#locationStep .primary-btn');
if (saveButton) saveButton.textContent = 'Download Missing LoRAs';
if (saveButton) saveButton.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs');
// Hide the back button
const backButton = document.querySelector('#locationStep .secondary-btn');

View File

@@ -234,6 +234,32 @@ export class ModalManager {
});
}
// Add bulkAddTagsModal registration
const bulkAddTagsModal = document.getElementById('bulkAddTagsModal');
if (bulkAddTagsModal) {
this.registerModal('bulkAddTagsModal', {
element: bulkAddTagsModal,
onClose: () => {
this.getModal('bulkAddTagsModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Register bulkBaseModelModal
const bulkBaseModelModal = document.getElementById('bulkBaseModelModal');
if (bulkBaseModelModal) {
this.registerModal('bulkBaseModelModal', {
element: bulkBaseModelModal,
onClose: () => {
this.getModal('bulkBaseModelModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true;
}

View File

@@ -1,10 +1,11 @@
import { showToast, updateFolderTags } from '../utils/uiHelpers.js';
import { showToast } from '../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../state/index.js';
import { modalManager } from './ModalManager.js';
import { bulkManager } from './BulkManager.js';
import { getStorageItem } from '../utils/storageHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { sidebarManager } from '../components/SidebarManager.js';
class MoveManager {
constructor() {
@@ -44,7 +45,7 @@ class MoveManager {
if (filePath === 'bulk') {
const selectedPaths = Array.from(state.selectedModels);
if (selectedPaths.length === 0) {
showToast('No models selected', 'warning');
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
this.bulkFilePaths = selectedPaths;
@@ -115,7 +116,7 @@ class MoveManager {
} catch (error) {
console.error(`Error fetching ${modelConfig.displayName.toLowerCase()} roots or folders:`, error);
showToast(error.message, 'error');
showToast('toast.models.moveFailed', { message: error.message }, 'error');
}
}
@@ -130,11 +131,11 @@ class MoveManager {
await this.folderTreeManager.loadTree(treeData.tree);
} else {
console.error('Failed to fetch folder tree:', treeData.error);
showToast('Failed to load folder tree', 'error');
showToast('toast.import.folderTreeFailed', {}, 'error');
}
} catch (error) {
console.error('Error initializing folder tree:', error);
showToast('Error loading folder tree', 'error');
showToast('toast.import.folderTreeError', {}, 'error');
}
}
@@ -162,7 +163,7 @@ class MoveManager {
const config = apiClient.apiConfig.config;
if (!selectedRoot) {
showToast(`Please select a ${config.displayName.toLowerCase()} root directory`, 'error');
showToast('toast.models.pleaseSelectRoot', { type: config.displayName.toLowerCase() }, 'error');
return;
}
@@ -177,51 +178,54 @@ class MoveManager {
try {
if (this.bulkFilePaths) {
// Bulk move mode
const movedFilePaths = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath);
const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath);
// Update virtual scroller if in active folder view
const pageState = getCurrentPageState();
if (pageState.activeFolder !== null && state.virtualScroller) {
// Remove only successfully moved items
movedFilePaths.forEach(newFilePath => {
// Find original filePath by matching filename
const filename = newFilePath.substring(newFilePath.lastIndexOf('/') + 1);
const originalFilePath = this.bulkFilePaths.find(fp => fp.endsWith('/' + filename));
if (originalFilePath) {
state.virtualScroller.removeItemByFilePath(originalFilePath);
// Remove items that were successfully moved
results.forEach(result => {
if (result.success) {
state.virtualScroller.removeItemByFilePath(result.original_file_path);
}
});
} else {
// Update the model cards' filepath in the DOM
movedFilePaths.forEach(newFilePath => {
const filename = newFilePath.substring(newFilePath.lastIndexOf('/') + 1);
const originalFilePath = this.bulkFilePaths.find(fp => fp.endsWith('/' + filename));
if (originalFilePath) {
state.virtualScroller.updateSingleItem(originalFilePath, {file_path: newFilePath});
// Update the model cards' filepath and filename in the DOM
results.forEach(result => {
if (result.success && result.new_file_path !== result.original_file_path) {
const newFileName = result.new_file_path.substring(result.new_file_path.lastIndexOf('/') + 1);
const baseFileName = newFileName.substring(0, newFileName.lastIndexOf('.'));
state.virtualScroller.updateSingleItem(result.original_file_path, {
file_path: result.new_file_path,
file_name: baseFileName
});
}
});
}
} else {
// Single move mode
const newFilePath = await apiClient.moveSingleModel(this.currentFilePath, targetPath);
const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath);
const pageState = getCurrentPageState();
if (newFilePath) {
if (result && result.new_file_path) {
if (pageState.activeFolder !== null && state.virtualScroller) {
state.virtualScroller.removeItemByFilePath(this.currentFilePath);
} else {
state.virtualScroller.updateSingleItem(this.currentFilePath, {file_path: newFilePath});
} else if (result.new_file_path !== this.currentFilePath) {
// Update both file_path and file_name if they changed
const newFileName = result.new_file_path.substring(result.new_file_path.lastIndexOf('/') + 1);
const baseFileName = newFileName.substring(0, newFileName.lastIndexOf('.'));
state.virtualScroller.updateSingleItem(this.currentFilePath, {
file_path: result.new_file_path,
file_name: baseFileName
});
}
}
}
// Refresh folder tags after successful move
try {
const foldersData = await apiClient.fetchModelFolders();
updateFolderTags(foldersData.folders);
} catch (error) {
console.error('Error refreshing folder tags:', error);
}
sidebarManager.refresh();
modalManager.closeModal('moveModal');
@@ -232,7 +236,7 @@ class MoveManager {
} catch (error) {
console.error('Error moving model(s):', error);
showToast('Failed to move model(s): ' + error.message, 'error');
showToast('toast.models.moveFailed', { message: error.message }, 'error');
}
}
}

View File

@@ -0,0 +1,488 @@
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { state } from '../state/index.js';
import { translate } from '../utils/i18nHelpers.js';
import { showToast } from '../utils/uiHelpers.js';
export class OnboardingManager {
constructor() {
this.isActive = false;
this.currentStep = 0;
this.selectedLanguage = 'en'; // Will be updated from state
this.overlay = null;
this.spotlight = null;
this.popup = null;
this.currentTarget = null; // Track current highlighted element
// Available languages with SVG flags (using flag-icons)
this.languages = [
{ code: 'en', name: 'English', flag: 'us' },
{ code: 'zh-CN', name: '简体中文', flag: 'cn' },
{ code: 'zh-TW', name: '繁體中文', flag: 'hk' },
{ code: 'ja', name: '日本語', flag: 'jp' },
{ code: 'ko', name: '한국어', flag: 'kr' },
{ code: 'es', name: 'Español', flag: 'es' },
{ code: 'fr', name: 'Français', flag: 'fr' },
{ code: 'de', name: 'Deutsch', flag: 'de' },
{ code: 'ru', name: 'Русский', flag: 'ru' }
];
// Tutorial steps configuration
this.steps = [
{
target: '.controls .action-buttons [data-action="fetch"]',
title: () => translate('onboarding.steps.fetch.title', {}, 'Fetch Models Metadata'),
content: () => translate('onboarding.steps.fetch.content', {}, 'Click the <strong>Fetch</strong> button to download model metadata and preview images from Civitai.'),
position: 'bottom'
},
{
target: '.controls .action-buttons [data-action="download"]',
title: () => translate('onboarding.steps.download.title', {}, 'Download New Models'),
content: () => translate('onboarding.steps.download.content', {}, 'Use the <strong>Download</strong> button to download models directly from Civitai URLs.'),
position: 'bottom'
},
{
target: '.controls .action-buttons [data-action="bulk"]',
title: () => translate('onboarding.steps.bulk.title', {}, 'Bulk Operations'),
content: () => translate('onboarding.steps.bulk.content', {}, 'Enter bulk mode by clicking this button or pressing <span class="onboarding-shortcut">B</span>. Select multiple models and perform batch operations. Use <span class="onboarding-shortcut">Ctrl+A</span> to select all visible models.'),
position: 'bottom'
},
{
target: '#searchOptionsToggle',
title: () => translate('onboarding.steps.searchOptions.title', {}, 'Search Options'),
content: () => translate('onboarding.steps.searchOptions.content', {}, 'Click this button to configure what fields to search in: filename, model name, tags, or creator name. Customize your search scope.'),
position: 'bottom'
},
{
target: '#filterButton',
title: () => translate('onboarding.steps.filter.title', {}, 'Filter Models'),
content: () => translate('onboarding.steps.filter.content', {}, 'Use filters to narrow down models by base model type (SD1.5, SDXL, Flux, etc.) or by specific tags.'),
position: 'bottom'
},
{
target: '#breadcrumbContainer',
title: () => translate('onboarding.steps.breadcrumb.title', {}, 'Breadcrumb Navigation'),
content: () => translate('onboarding.steps.breadcrumb.content', {}, 'The breadcrumb navigation shows your current path and allows quick navigation between folders. Click any folder name to jump directly there.'),
position: 'bottom'
},
{
target: '.card-grid',
title: () => translate('onboarding.steps.modelCards.title', {}, 'Model Cards'),
content: () => translate('onboarding.steps.modelCards.content', {}, '<strong>Single-click</strong> a model card to view detailed information and edit metadata. Look for the pencil icon when hovering over editable fields.'),
position: 'top',
customPosition: { top: '20%', left: '50%' }
},
{
target: '.card-grid',
title: () => translate('onboarding.steps.contextMenu.title', {}, 'Context Menu'),
content: () => translate('onboarding.steps.contextMenu.content', {}, '<strong>Right-click</strong> any model card for a context menu with additional actions.'),
position: 'top',
customPosition: { top: '20%', left: '50%' }
}
];
}
// Check if user should see onboarding
shouldShowOnboarding() {
const completed = getStorageItem('onboarding_completed');
const skipped = getStorageItem('onboarding_skipped');
return !completed && !skipped;
}
// Start the onboarding process
async start() {
if (!this.shouldShowOnboarding()) {
return;
}
// If language has already been set, skip language selection
if (getStorageItem('onboarding_language_set')) {
this.startTutorial();
return;
}
// Show language selection first
await this.showLanguageSelection();
}
// Show language selection modal
showLanguageSelection() {
return new Promise((resolve) => {
// Initialize selected language from current settings
this.selectedLanguage = state.global.settings.language || 'en';
const modal = document.createElement('div');
modal.className = 'language-selection-modal';
modal.innerHTML = `
<div class="language-selection-content">
<h2>${translate('onboarding.languageSelection.title', {}, 'Welcome to LoRA Manager')}</h2>
<p>Choose Your Language / 选择语言 / 言語を選択</p>
<div class="language-grid">
${this.languages.map(lang => `
<div class="language-option" data-language="${lang.code}">
<span class="language-flag">
<span class="fi fi-${lang.flag}"></span>
</span>
<span class="language-name">${lang.name}</span>
</div>
`).join('')}
</div>
<div class="language-actions">
<button class="onboarding-btn" id="skipLanguageBtn">${translate('onboarding.tutorial.skipTutorial', {}, 'Skip Tutorial')}</button>
<button class="onboarding-btn primary" id="continueLanguageBtn">${translate('onboarding.languageSelection.continue', {}, 'Continue')}</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Handle language selection
modal.querySelectorAll('.language-option').forEach(option => {
option.addEventListener('click', () => {
modal.querySelectorAll('.language-option').forEach(opt => opt.classList.remove('selected'));
option.classList.add('selected');
this.selectedLanguage = option.dataset.language;
});
});
// Handle continue button
document.getElementById('continueLanguageBtn').addEventListener('click', async () => {
const currentLanguage = state.global.settings.language || 'en';
// Only change language if it's different from current setting
if (this.selectedLanguage !== currentLanguage) {
await this.changeLanguage(this.selectedLanguage);
} else {
document.body.removeChild(modal);
this.startTutorial();
resolve();
}
});
// Handle skip button - skip entire tutorial
document.getElementById('skipLanguageBtn').addEventListener('click', () => {
document.body.removeChild(modal);
this.skip(); // Skip entire tutorial instead of just language selection
resolve();
});
// Select current language by default
const currentLanguageOption = modal.querySelector(`[data-language="${this.selectedLanguage}"]`);
if (currentLanguageOption) {
currentLanguageOption.classList.add('selected');
} else {
// Fallback to English if current language not found
modal.querySelector('[data-language="en"]').classList.add('selected');
}
});
}
// Change language using existing settings manager
async changeLanguage(languageCode) {
try {
// Update state
state.global.settings.language = languageCode;
// Save to localStorage
setStorageItem('settings', state.global.settings);
// Save to backend
const response = await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
language: languageCode
})
});
if (response.ok) {
// Mark onboarding as started before reload
setStorageItem('onboarding_language_set', true);
window.location.reload();
}
} catch (error) {
console.error('Failed to change language:', error);
showToast('onboarding.languageSelection.changeFailed', { message: error.message }, 'error');
}
}
// Start the tutorial steps
startTutorial() {
this.isActive = true;
this.currentStep = 0;
this.createOverlay();
this.showStep(0);
}
// Create overlay elements
createOverlay() {
// Create overlay
this.overlay = document.createElement('div');
this.overlay.className = 'onboarding-overlay active';
document.body.appendChild(this.overlay);
// Create spotlight
this.spotlight = document.createElement('div');
this.spotlight.className = 'onboarding-spotlight';
document.body.appendChild(this.spotlight);
// Create popup
this.popup = document.createElement('div');
this.popup.className = 'onboarding-popup';
document.body.appendChild(this.popup);
}
// Show specific step
showStep(stepIndex) {
if (stepIndex >= this.steps.length) {
this.complete();
return;
}
const step = this.steps[stepIndex];
const target = document.querySelector(step.target);
if (!target && step.target !== 'body') {
// Skip this step if target not found
this.showStep(stepIndex + 1);
return;
}
// Clear previous target highlighting
this.clearTargetHighlight();
// Position spotlight and create mask
if (target && step.target !== 'body') {
this.highlightTarget(target);
} else {
this.spotlight.style.display = 'none';
this.clearOverlayMask();
}
// Update popup content
this.popup.innerHTML = `
<h3>${typeof step.title === 'function' ? step.title() : step.title}</h3>
<p>${typeof step.content === 'function' ? step.content() : step.content}</p>
<div class="onboarding-controls">
<div class="onboarding-progress">
<span>${stepIndex + 1} / ${this.steps.length}</span>
</div>
<div class="onboarding-actions">
<button class="onboarding-btn" onclick="onboardingManager.skip()">${translate('onboarding.tutorial.skipTutorial', {}, 'Skip Tutorial')}</button>
${stepIndex > 0 ? `<button class="onboarding-btn" onclick="onboardingManager.previousStep()">${translate('onboarding.tutorial.back', {}, 'Back')}</button>` : ''}
<button class="onboarding-btn primary" onclick="onboardingManager.nextStep()">
${stepIndex === this.steps.length - 1 ? translate('onboarding.tutorial.finish', {}, 'Finish') : translate('onboarding.tutorial.next', {}, 'Next')}
</button>
</div>
</div>
`;
// Position popup
this.positionPopup(step, target);
this.currentStep = stepIndex;
}
// Position popup relative to target
positionPopup(step, target) {
const popup = this.popup;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
if (step.customPosition) {
popup.style.left = step.customPosition.left;
popup.style.top = step.customPosition.top;
popup.style.transform = 'translate(-50%, 0)';
return;
}
if (!target || step.target === 'body') {
popup.style.left = '50%';
popup.style.top = '50%';
popup.style.transform = 'translate(-50%, -50%)';
return;
}
const rect = target.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
let left, top;
switch (step.position) {
case 'bottom':
left = rect.left + (rect.width / 2) - (popupRect.width / 2);
top = rect.bottom + 20;
break;
case 'top':
left = rect.left + (rect.width / 2) - (popupRect.width / 2);
top = rect.top - popupRect.height - 20;
break;
case 'right':
left = rect.right + 20;
top = rect.top + (rect.height / 2) - (popupRect.height / 2);
break;
case 'left':
left = rect.left - popupRect.width - 20;
top = rect.top + (rect.height / 2) - (popupRect.height / 2);
break;
default:
left = rect.left + (rect.width / 2) - (popupRect.width / 2);
top = rect.bottom + 20;
}
// Ensure popup stays within viewport
left = Math.max(20, Math.min(left, windowWidth - popupRect.width - 20));
top = Math.max(20, Math.min(top, windowHeight - popupRect.height - 20));
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
popup.style.transform = 'none';
}
// Highlight target element with mask approach
highlightTarget(target) {
const rect = target.getBoundingClientRect();
const padding = 4; // Padding around the target element
const offset = 3; // Shift spotlight up and left by 3px
// Position spotlight
this.spotlight.style.left = `${rect.left - padding - offset}px`;
this.spotlight.style.top = `${rect.top - padding - offset}px`;
this.spotlight.style.width = `${rect.width + padding * 2}px`;
this.spotlight.style.height = `${rect.height + padding * 2}px`;
this.spotlight.style.display = 'block';
// Create mask for overlay to cut out the highlighted area
this.createOverlayMask(rect, padding, offset);
// Add highlight class to target and ensure it's interactive
target.classList.add('onboarding-target-highlight');
this.currentTarget = target;
// Add pulsing animation
this.spotlight.classList.add('onboarding-highlight');
}
// Create mask for overlay to cut out highlighted area
createOverlayMask(rect, padding, offset = 0) {
const x = rect.left - padding - offset;
const y = rect.top - padding - offset;
const width = rect.width + padding * 2;
const height = rect.height + padding * 2;
// Create SVG mask
const maskId = 'onboarding-mask';
let maskSvg = document.getElementById(maskId);
if (!maskSvg) {
maskSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
maskSvg.id = maskId;
maskSvg.style.position = 'absolute';
maskSvg.style.top = '0';
maskSvg.style.left = '0';
maskSvg.style.width = '100%';
maskSvg.style.height = '100%';
maskSvg.style.pointerEvents = 'none';
document.body.appendChild(maskSvg);
}
// Clear existing mask content
maskSvg.innerHTML = `
<defs>
<mask id="overlay-mask">
<rect width="100%" height="100%" fill="white"/>
<rect x="${x}" y="${y}" width="${width}" height="${height}"
rx="8" ry="8" fill="black"/>
</mask>
</defs>
`;
// Apply mask to overlay
this.overlay.style.mask = 'url(#overlay-mask)';
this.overlay.style.webkitMask = 'url(#overlay-mask)';
}
// Clear overlay mask
clearOverlayMask() {
if (this.overlay) {
this.overlay.style.mask = 'none';
this.overlay.style.webkitMask = 'none';
}
const maskSvg = document.getElementById('onboarding-mask');
if (maskSvg) {
maskSvg.remove();
}
}
// Clear target highlighting
clearTargetHighlight() {
if (this.currentTarget) {
this.currentTarget.classList.remove('onboarding-target-highlight');
this.currentTarget = null;
}
if (this.spotlight) {
this.spotlight.classList.remove('onboarding-highlight');
}
}
// Navigate to next step
nextStep() {
this.showStep(this.currentStep + 1);
}
// Navigate to previous step
previousStep() {
if (this.currentStep > 0) {
this.showStep(this.currentStep - 1);
}
}
// Skip the tutorial
skip() {
setStorageItem('onboarding_skipped', true);
this.cleanup();
}
// Complete the tutorial
complete() {
setStorageItem('onboarding_completed', true);
this.cleanup();
}
// Clean up overlay elements
cleanup() {
this.clearTargetHighlight();
this.clearOverlayMask();
if (this.overlay) {
document.body.removeChild(this.overlay);
this.overlay = null;
}
if (this.spotlight) {
document.body.removeChild(this.spotlight);
this.spotlight = null;
}
if (this.popup) {
document.body.removeChild(this.popup);
this.popup = null;
}
this.isActive = false;
}
// Reset onboarding status (for testing)
reset() {
localStorage.removeItem('lora_manager_onboarding_completed');
localStorage.removeItem('lora_manager_onboarding_skipped');
localStorage.removeItem('lora_manager_onboarding_language_set');
localStorage.setItem('lora_manager_version_info', '0.8.30-2546581');
}
}
// Create singleton instance
export const onboardingManager = new OnboardingManager();
// Make it globally available for button handlers
window.onboardingManager = onboardingManager;

View File

@@ -1,4 +1,4 @@
import { updatePanelPositions } from "../utils/uiHelpers.js";
import { updatePanelPositions, showToast } from "../utils/uiHelpers.js";
import { getCurrentPageState } from "../state/index.js";
import { getModelApiClient } from "../api/modelApiFactory.js";
import { setStorageItem, getStorageItem } from "../utils/storageHelpers.js";
@@ -19,7 +19,6 @@ export class SearchManager {
this.searchOptionsPanel = document.getElementById('searchOptionsPanel');
this.closeSearchOptions = document.getElementById('closeSearchOptions');
this.searchOptionTags = document.querySelectorAll('.search-option-tag');
this.recursiveSearchToggle = document.getElementById('recursiveSearchToggle');
this.searchTimeout = null;
this.currentPage = options.page || document.body.dataset.page || 'loras';
@@ -98,10 +97,7 @@ export class SearchManager {
// Check if clicking would deselect the last active option
const activeOptions = document.querySelectorAll('.search-option-tag.active');
if (activeOptions.length === 1 && activeOptions[0] === tag) {
// Don't allow deselecting the last option
if (typeof showToast === 'function') {
showToast('At least one search option must be selected', 'info');
}
showToast('toast.search.atLeastOneOption', {}, 'info');
return;
}
@@ -112,14 +108,6 @@ export class SearchManager {
});
}
// Recursive search toggle
if (this.recursiveSearchToggle) {
this.recursiveSearchToggle.addEventListener('change', () => {
this.saveSearchPreferences();
this.performSearch();
});
}
// Add global click handler to close panels when clicking outside
document.addEventListener('click', (e) => {
// Close search options panel when clicking outside
@@ -218,11 +206,6 @@ export class SearchManager {
});
}
// Apply recursive search - only if the toggle exists
if (this.recursiveSearchToggle && preferences.recursive !== undefined) {
this.recursiveSearchToggle.checked = preferences.recursive;
}
// Ensure at least one search option is selected
this.validateSearchOptions();
} catch (error) {
@@ -272,11 +255,6 @@ export class SearchManager {
options
};
// Only add recursive option if the toggle exists
if (this.recursiveSearchToggle) {
preferences.recursive = this.recursiveSearchToggle.checked;
}
setStorageItem(`${this.currentPage}_search_prefs`, preferences);
} catch (error) {
console.error('Error saving search preferences:', error);
@@ -294,7 +272,6 @@ export class SearchManager {
performSearch() {
const query = this.searchInput.value.trim();
const options = this.getActiveSearchOptions();
const recursive = this.recursiveSearchToggle ? this.recursiveSearchToggle.checked : false;
// Update the state with search parameters
const pageState = getCurrentPageState();
@@ -318,16 +295,14 @@ export class SearchManager {
filename: options.filename || false,
modelname: options.modelname || false,
tags: options.tags || false,
creator: options.creator || false,
recursive: recursive
creator: options.creator || false
};
} else if (this.currentPage === 'checkpoints') {
pageState.searchOptions = {
filename: options.filename || false,
modelname: options.modelname || false,
tags: options.tags || false,
creator: options.creator || false,
recursive: recursive
creator: options.creator || false
};
}
}

View File

@@ -4,6 +4,7 @@ import { state } from '../state/index.js';
import { resetAndReload } from '../api/modelApiFactory.js';
import { setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES } from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js';
export class SettingsManager {
constructor() {
@@ -270,6 +271,13 @@ export class SettingsManager {
// Load default embedding root
await this.loadEmbeddingRoots();
// Load language setting
const languageSelect = document.getElementById('languageSelect');
if (languageSelect) {
const currentLanguage = state.global.settings.language || 'en';
languageSelect.value = currentLanguage;
}
}
async loadLoraRoots() {
@@ -307,7 +315,7 @@ export class SettingsManager {
} catch (error) {
console.error('Error loading LoRA roots:', error);
showToast('Failed to load LoRA roots: ' + error.message, 'error');
showToast('toast.settings.loraRootsFailed', { message: error.message }, 'error');
}
}
@@ -346,7 +354,7 @@ export class SettingsManager {
} catch (error) {
console.error('Error loading checkpoint roots:', error);
showToast('Failed to load checkpoint roots: ' + error.message, 'error');
showToast('toast.settings.checkpointRootsFailed', { message: error.message }, 'error');
}
}
@@ -385,7 +393,7 @@ export class SettingsManager {
} catch (error) {
console.error('Error loading embedding roots:', error);
showToast('Failed to load embedding roots: ' + error.message, 'error');
showToast('toast.settings.embeddingRootsFailed', { message: error.message }, 'error');
}
}
@@ -424,13 +432,13 @@ export class SettingsManager {
row.innerHTML = `
<div class="mapping-controls">
<select class="base-model-select">
<option value="">Select Base Model</option>
<option value="">${translate('settings.downloadPathTemplates.selectBaseModel', {}, 'Select Base Model')}</option>
${availableModels.map(model =>
`<option value="${model}" ${model === baseModel ? 'selected' : ''}>${model}</option>`
).join('')}
</select>
<input type="text" class="path-value-input" placeholder="Custom path (e.g., flux)" value="${pathValue}">
<button type="button" class="remove-mapping-btn" title="Remove mapping">
<input type="text" class="path-value-input" placeholder="${translate('settings.downloadPathTemplates.customPathPlaceholder', {}, 'Custom path (e.g., flux)')}" value="${pathValue}">
<button type="button" class="remove-mapping-btn" title="${translate('settings.downloadPathTemplates.removeMapping', {}, 'Remove mapping')}">
<i class="fas fa-times"></i>
</button>
</div>
@@ -523,7 +531,7 @@ export class SettingsManager {
);
// Rebuild options
select.innerHTML = '<option value="">Select Base Model</option>' +
select.innerHTML = `<option value="">${translate('settings.downloadPathTemplates.selectBaseModel', {}, 'Select Base Model')}</option>` +
availableModels.map(model =>
`<option value="${model}" ${model === currentValue ? 'selected' : ''}>${model}</option>`
).join('');
@@ -553,14 +561,17 @@ export class SettingsManager {
// Show success toast
const mappingCount = Object.keys(state.global.settings.base_model_path_mappings).length;
if (mappingCount > 0) {
showToast(`Base model path mappings updated (${mappingCount} mapping${mappingCount !== 1 ? 's' : ''})`, 'success');
showToast('toast.settings.mappingsUpdated', {
count: mappingCount,
plural: mappingCount !== 1 ? 's' : ''
}, 'success');
} else {
showToast('Base model path mappings cleared', 'success');
showToast('toast.settings.mappingsCleared', {}, 'success');
}
} catch (error) {
console.error('Error saving base model mappings:', error);
showToast('Failed to save base model mappings: ' + error.message, 'error');
showToast('toast.settings.mappingSaveFailed', { message: error.message }, 'error');
}
}
@@ -652,7 +663,7 @@ export class SettingsManager {
validationElement.className = 'template-validation';
if (!template) {
validationElement.innerHTML = '<i class="fas fa-check"></i> Valid (flat structure)';
validationElement.innerHTML = `<i class="fas fa-check"></i> ${translate('settings.downloadPathTemplates.validation.validFlat', {}, 'Valid (flat structure)')}`;
validationElement.classList.add('valid');
return true;
}
@@ -660,21 +671,21 @@ export class SettingsManager {
// Check for invalid characters
const invalidChars = /[<>:"|?*]/;
if (invalidChars.test(template)) {
validationElement.innerHTML = '<i class="fas fa-times"></i> Invalid characters detected';
validationElement.innerHTML = `<i class="fas fa-times"></i> ${translate('settings.downloadPathTemplates.validation.invalidChars', {}, 'Invalid characters detected')}`;
validationElement.classList.add('invalid');
return false;
}
// Check for double slashes
if (template.includes('//')) {
validationElement.innerHTML = '<i class="fas fa-times"></i> Double slashes not allowed';
validationElement.innerHTML = `<i class="fas fa-times"></i> ${translate('settings.downloadPathTemplates.validation.doubleSlashes', {}, 'Double slashes not allowed')}`;
validationElement.classList.add('invalid');
return false;
}
// Check if it starts or ends with slash
if (template.startsWith('/') || template.endsWith('/')) {
validationElement.innerHTML = '<i class="fas fa-times"></i> Cannot start or end with slash';
validationElement.innerHTML = `<i class="fas fa-times"></i> ${translate('settings.downloadPathTemplates.validation.leadingTrailingSlash', {}, 'Cannot start or end with slash')}`;
validationElement.classList.add('invalid');
return false;
}
@@ -689,13 +700,13 @@ export class SettingsManager {
);
if (invalidPlaceholders.length > 0) {
validationElement.innerHTML = `<i class="fas fa-times"></i> Invalid placeholder: ${invalidPlaceholders[0]}`;
validationElement.innerHTML = `<i class="fas fa-times"></i> ${translate('settings.downloadPathTemplates.validation.invalidPlaceholder', { placeholder: invalidPlaceholders[0] }, `Invalid placeholder: ${invalidPlaceholders[0]}`)}`;
validationElement.classList.add('invalid');
return false;
}
// Template is valid
validationElement.innerHTML = '<i class="fas fa-check"></i> Valid template';
validationElement.innerHTML = `<i class="fas fa-check"></i> ${translate('settings.downloadPathTemplates.validation.validTemplate', {}, 'Valid template')}`;
validationElement.classList.add('valid');
return true;
}
@@ -737,11 +748,11 @@ export class SettingsManager {
throw new Error('Failed to save download path templates');
}
showToast('Download path templates updated', 'success');
showToast('toast.settings.downloadTemplatesUpdated', {}, 'success');
} catch (error) {
console.error('Error saving download path templates:', error);
showToast('Failed to save download path templates: ' + error.message, 'error');
showToast('toast.settings.downloadTemplatesFailed', { message: error.message }, 'error');
}
}
@@ -802,7 +813,7 @@ export class SettingsManager {
}
}
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success');
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
// Apply frontend settings immediately
this.applyFrontendSettings();
@@ -823,11 +834,13 @@ export class SettingsManager {
// Recalculate layout when compact mode changes
if (settingKey === 'compact_mode' && state.virtualScroller) {
state.virtualScroller.calculateLayout();
showToast(`Compact Mode ${value ? 'enabled' : 'disabled'}`, 'success');
showToast('toast.settings.compactModeToggled', {
state: value ? 'toast.settings.compactModeEnabled' : 'toast.settings.compactModeDisabled'
}, 'success');
}
} catch (error) {
showToast('Failed to save setting: ' + error.message, 'error');
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
@@ -881,7 +894,7 @@ export class SettingsManager {
throw new Error('Failed to save setting');
}
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success');
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
}
// Apply frontend settings immediately
@@ -895,11 +908,11 @@ export class SettingsManager {
if (value === 'medium') densityName = "Medium";
if (value === 'compact') densityName = "Compact";
showToast(`Display Density set to ${densityName}`, 'success');
showToast('toast.settings.displayDensitySet', { density: densityName }, 'success');
}
} catch (error) {
showToast('Failed to save setting: ' + error.message, 'error');
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
@@ -938,10 +951,46 @@ export class SettingsManager {
throw new Error('Failed to save setting');
}
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success');
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
} catch (error) {
showToast('Failed to save setting: ' + error.message, 'error');
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
async saveLanguageSetting() {
const element = document.getElementById('languageSelect');
if (!element) return;
const selectedLanguage = element.value;
try {
// Update local state
state.global.settings.language = selectedLanguage;
// Save to localStorage
setStorageItem('settings', state.global.settings);
// Save to backend
const response = await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
language: selectedLanguage
})
});
if (!response.ok) {
throw new Error('Failed to save language setting to backend');
}
// Reload the page to apply the new language
window.location.reload();
} catch (error) {
showToast('toast.settings.languageChangeFailed', { message: error.message }, 'error');
}
}
@@ -976,15 +1025,15 @@ export class SettingsManager {
const result = await response.json();
if (result.success) {
showToast('Cache files have been cleared successfully. Cache will rebuild on next action.', 'success');
showToast('toast.settings.cacheCleared', {}, 'success');
} else {
showToast(`Failed to clear cache: ${result.error}`, 'error');
showToast('toast.settings.cacheClearFailed', { error: result.error }, 'error');
}
// Close the confirmation modal
modalManager.closeModal('clearCacheModal');
} catch (error) {
showToast(`Error clearing cache: ${error.message}`, 'error');
showToast('toast.settings.cacheClearError', { message: error.message }, 'error');
modalManager.closeModal('clearCacheModal');
}
}

View File

@@ -8,6 +8,7 @@ import {
resetDismissedBanner
} from '../utils/storageHelpers.js';
import { bannerService } from './BannerService.js';
import { translate } from '../utils/i18nHelpers.js';
export class UpdateService {
constructor() {
@@ -165,8 +166,8 @@ export class UpdateService {
if (updateToggle) {
updateToggle.title = this.updateNotificationsEnabled && this.updateAvailable
? "Update Available"
: "Check Updates";
? translate('update.updateAvailable')
: translate('update.title');
}
// Force updating badges visibility based on current state
@@ -185,7 +186,9 @@ export class UpdateService {
// Update title based on update availability
const headerTitle = modal.querySelector('.update-header h2');
if (headerTitle) {
headerTitle.textContent = this.updateAvailable ? "Update Available" : "Check for Updates";
headerTitle.textContent = this.updateAvailable ?
translate('update.updateAvailable') :
translate('update.title');
}
// Always update version information, even if updateInfo is null
@@ -209,9 +212,9 @@ export class UpdateService {
const gitInfoEl = modal.querySelector('.git-info');
if (gitInfoEl && this.gitInfo) {
if (this.gitInfo.short_hash !== 'unknown') {
let gitText = `Commit: ${this.gitInfo.short_hash}`;
let gitText = `${translate('update.commit')}: ${this.gitInfo.short_hash}`;
if (this.gitInfo.commit_date !== 'unknown') {
gitText += ` - Date: ${this.gitInfo.commit_date}`;
gitText += ` - ${translate('common.status.date', {}, 'Date')}: ${this.gitInfo.commit_date}`;
}
gitInfoEl.textContent = gitText;
gitInfoEl.style.display = 'block';
@@ -231,7 +234,7 @@ export class UpdateService {
changelogItem.className = 'changelog-item';
const versionHeader = document.createElement('h4');
versionHeader.textContent = `Version ${this.latestVersion}`;
versionHeader.textContent = `${translate('common.status.version', {}, 'Version')} ${this.latestVersion}`;
changelogItem.appendChild(versionHeader);
// Create changelog list
@@ -247,7 +250,7 @@ export class UpdateService {
} else {
// If no changelog items available
const listItem = document.createElement('li');
listItem.textContent = "No detailed changelog available. Check GitHub for more information.";
listItem.textContent = translate('update.noChangelogAvailable', {}, 'No detailed changelog available. Check GitHub for more information.');
changelogList.appendChild(listItem);
}
@@ -271,11 +274,11 @@ export class UpdateService {
try {
this.isUpdating = true;
this.updateUpdateUI('updating', 'Updating...');
this.updateUpdateUI('updating', translate('update.status.updating'));
this.showUpdateProgress(true);
// Update progress
this.updateProgress(10, 'Preparing update...');
this.updateProgress(10, translate('update.updateProgress.preparing'));
const response = await fetch('/api/perform-update', {
method: 'POST',
@@ -287,13 +290,13 @@ export class UpdateService {
})
});
this.updateProgress(50, 'Installing update...');
this.updateProgress(50, translate('update.updateProgress.installing'));
const data = await response.json();
if (data.success) {
this.updateProgress(100, 'Update completed successfully!');
this.updateUpdateUI('success', 'Updated!');
this.updateProgress(100, translate('update.updateProgress.completed'));
this.updateUpdateUI('success', translate('update.status.updated'));
// Show success message and suggest restart
setTimeout(() => {
@@ -301,13 +304,13 @@ export class UpdateService {
}, 1000);
} else {
throw new Error(data.error || 'Update failed');
throw new Error(data.error || translate('update.status.updateFailed'));
}
} catch (error) {
console.error('Update failed:', error);
this.updateUpdateUI('error', 'Update Failed');
this.updateProgress(0, `Update failed: ${error.message}`);
this.updateUpdateUI('error', translate('update.status.updateFailed'));
this.updateProgress(0, translate('update.updateProgress.failed', { error: error.message }));
// Hide progress after error
setTimeout(() => {
@@ -369,11 +372,11 @@ export class UpdateService {
progressText.innerHTML = `
<div style="text-align: center; color: var(--lora-success);">
<i class="fas fa-check-circle" style="margin-right: 8px;"></i>
Successfully updated to ${newVersion}!
${translate('update.completion.successMessage', { version: newVersion })}
<br><br>
<div style="opacity: 0.95; color: var(--lora-error); font-size: 1em;">
Please restart ComfyUI or LoRA Manager to apply update.<br>
Make sure to reload your browser for both LoRA Manager and ComfyUI.
${translate('update.completion.restartMessage')}<br>
${translate('update.completion.reloadMessage')}
</div>
</div>
`;
@@ -470,16 +473,19 @@ export class UpdateService {
registerVersionMismatchBanner() {
// Get stored and current version for display
const storedVersion = getStoredVersionInfo() || 'unknown';
const currentVersion = this.currentVersionInfo || 'unknown';
const storedVersion = getStoredVersionInfo() || translate('common.status.unknown');
const currentVersion = this.currentVersionInfo || translate('common.status.unknown');
bannerService.registerBanner('version-mismatch', {
id: 'version-mismatch',
title: 'Application Update Detected',
content: `Your browser is running an outdated version of LoRA Manager (${storedVersion}). The server has been updated to version ${currentVersion}. Please refresh to ensure proper functionality.`,
title: translate('banners.versionMismatch.title', {}, 'Application Update Detected'),
content: translate('banners.versionMismatch.content', {
storedVersion,
currentVersion
}, `Your browser is running an outdated version of LoRA Manager (${storedVersion}). The server has been updated to version ${currentVersion}. Please refresh to ensure proper functionality.`),
actions: [
{
text: 'Refresh Now',
text: translate('banners.versionMismatch.refreshNow', {}, 'Refresh Now'),
icon: 'fas fa-sync',
action: 'hardRefresh',
type: 'primary'
@@ -492,7 +498,7 @@ export class UpdateService {
// Add countdown element
const countdownEl = document.createElement('div');
countdownEl.className = 'banner-countdown';
countdownEl.innerHTML = `<span>Refreshing in <strong>15</strong> seconds...</span>`;
countdownEl.innerHTML = `<span>${translate('banners.versionMismatch.refreshingIn', {}, 'Refreshing in')} <strong>15</strong> ${translate('banners.versionMismatch.seconds', {}, 'seconds')}...</span>`;
bannerElement.querySelector('.banner-content').appendChild(countdownEl);
// Start countdown

View File

@@ -1,4 +1,5 @@
import { showToast } from '../../utils/uiHelpers.js';
import { translate } from '../../utils/i18nHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { MODEL_TYPES } from '../../api/apiConfig.js';
import { getStorageItem } from '../../utils/storageHelpers.js';
@@ -13,13 +14,13 @@ export class DownloadManager {
const isDownloadOnly = !!this.importManager.recipeId;
if (!isDownloadOnly && !this.importManager.recipeName) {
showToast('Please enter a recipe name', 'error');
showToast('toast.recipes.enterRecipeName', {}, 'error');
return;
}
try {
// Show progress indicator
this.importManager.loadingManager.showSimpleLoading(isDownloadOnly ? 'Downloading LoRAs...' : 'Saving recipe...');
this.importManager.loadingManager.showSimpleLoading(isDownloadOnly ? translate('recipes.controls.import.downloadingLoras', {}, 'Downloading LoRAs...') : translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...'));
// Only send the complete recipe to save if not in download-only mode
if (!isDownloadOnly) {
@@ -77,7 +78,7 @@ export class DownloadManager {
if (!result.success) {
// Handle save error
console.error("Failed to save recipe:", result.error);
showToast(result.error, 'error');
showToast('toast.recipes.recipeSaveFailed', { error: result.error }, 'error');
// Close modal
modalManager.closeModal('importModal');
return;
@@ -93,10 +94,10 @@ export class DownloadManager {
// Show success message
if (isDownloadOnly) {
if (failedDownloads === 0) {
showToast('LoRAs downloaded successfully', 'success');
showToast('toast.loras.downloadSuccessful', {}, 'success');
}
} else {
showToast(`Recipe "${this.importManager.recipeName}" saved successfully`, 'success');
showToast('toast.recipes.nameSaved', { name: this.importManager.recipeName }, 'success');
}
// Close modal
@@ -107,7 +108,7 @@ export class DownloadManager {
} catch (error) {
console.error('Error:', error);
showToast(error.message, 'error');
showToast('toast.recipes.processingError', { message: error.message }, 'error');
} finally {
this.importManager.loadingManager.hide();
}
@@ -117,7 +118,7 @@ export class DownloadManager {
// For download, we need to validate the target path
const loraRoot = document.getElementById('importLoraRoot')?.value;
if (!loraRoot) {
throw new Error('Please select a LoRA root directory');
throw new Error(translate('recipes.controls.import.errors.selectLoraRoot', {}, 'Please select a LoRA root directory'));
}
// Build target path
@@ -195,7 +196,7 @@ export class DownloadManager {
currentLoraProgress = 0;
// Initial status update for new LoRA
this.importManager.loadingManager.setStatus(`Starting download for LoRA ${i+1}/${this.importManager.downloadableLoRAs.length}`);
this.importManager.loadingManager.setStatus(translate('recipes.controls.import.startingDownload', { current: i+1, total: this.importManager.downloadableLoRAs.length }, `Starting download for LoRA ${i+1}/${this.importManager.downloadableLoRAs.length}`));
updateProgress(0, completedDownloads, lora.name);
try {
@@ -238,15 +239,19 @@ export class DownloadManager {
// Show appropriate completion message based on results
if (failedDownloads === 0) {
showToast(`All ${completedDownloads} LoRAs downloaded successfully`, 'success');
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
if (accessFailures > 0) {
showToast(
`Downloaded ${completedDownloads} of ${this.importManager.downloadableLoRAs.length} LoRAs. ${accessFailures} failed due to access restrictions. Check your API key in settings or early access status.`,
'error'
);
showToast('toast.loras.downloadPartialWithAccess', {
completed: completedDownloads,
total: this.importManager.downloadableLoRAs.length,
accessFailures: accessFailures
}, 'error');
} else {
showToast(`Downloaded ${completedDownloads} of ${this.importManager.downloadableLoRAs.length} LoRAs`, 'error');
showToast('toast.loras.downloadPartialSuccess', {
completed: completedDownloads,
total: this.importManager.downloadableLoRAs.length
}, 'error');
}
}

Some files were not shown because too many files have changed in this diff Show More