From 692796db4629ee044d4020fb1f1b1907f7292a4f Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Thu, 24 Jul 2025 19:56:18 +0800 Subject: [PATCH 01/20] refactor: Update API endpoints to include 'loras' prefix for consistency --- py/routes/lora_routes.py | 16 ++++++++-------- static/js/api/loraApi.js | 2 +- .../components/ContextMenu/RecipeContextMenu.js | 4 ++-- static/js/components/RecipeModal.js | 4 ++-- static/js/components/shared/ModelDescription.js | 2 +- static/js/managers/MoveManager.js | 4 ++-- web/comfyui/legacy_loras_widget.js | 4 ++-- web/comfyui/loras_widget_components.js | 2 +- web/comfyui/loras_widget_events.js | 2 +- web/comfyui/utils.js | 2 +- 10 files changed, 21 insertions(+), 21 deletions(-) diff --git a/py/routes/lora_routes.py b/py/routes/lora_routes.py index ac7dc4ed..f88d03ec 100644 --- a/py/routes/lora_routes.py +++ b/py/routes/lora_routes.py @@ -45,21 +45,21 @@ class LoraRoutes(BaseModelRoutes): app.router.add_get(f'/api/{prefix}/letter-counts', self.get_letter_counts) app.router.add_get(f'/api/{prefix}/get-notes', self.get_lora_notes) app.router.add_get(f'/api/{prefix}/get-trigger-words', self.get_lora_trigger_words) - app.router.add_get(f'/api/lora-preview-url', self.get_lora_preview_url) - app.router.add_get(f'/api/lora-civitai-url', self.get_lora_civitai_url) - app.router.add_get(f'/api/lora-model-description', self.get_lora_model_description) + app.router.add_get(f'/api/{prefix}/preview-url', self.get_lora_preview_url) + app.router.add_get(f'/api/{prefix}/civitai-url', self.get_lora_civitai_url) + app.router.add_get(f'/api/{prefix}/model-description', self.get_lora_model_description) # LoRA-specific management routes - app.router.add_post(f'/api/move_model', self.move_model) - app.router.add_post(f'/api/move_models_bulk', self.move_models_bulk) + app.router.add_post(f'/api/{prefix}/move_model', self.move_model) + app.router.add_post(f'/api/{prefix}/move_models_bulk', self.move_models_bulk) # CivitAI integration with LoRA-specific validation app.router.add_get(f'/api/{prefix}/civitai/versions/{{model_id}}', self.get_civitai_versions_lora) - app.router.add_get(f'/api/civitai/model/version/{{modelVersionId}}', self.get_civitai_model_by_version) - app.router.add_get(f'/api/civitai/model/hash/{{hash}}', self.get_civitai_model_by_hash) + app.router.add_get(f'/api/{prefix}/civitai/model/version/{{modelVersionId}}', self.get_civitai_model_by_version) + app.router.add_get(f'/api/{prefix}/civitai/model/hash/{{hash}}', self.get_civitai_model_by_hash) # ComfyUI integration - app.router.add_post(f'/loramanager/get_trigger_words', self.get_trigger_words) + app.router.add_post(f'/api/{prefix}/get_trigger_words', self.get_trigger_words) def _parse_specific_params(self, request: web.Request) -> Dict: """Parse LoRA-specific parameters""" diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js index 9d5dd1bc..a7f0bccd 100644 --- a/static/js/api/loraApi.js +++ b/static/js/api/loraApi.js @@ -125,7 +125,7 @@ export async function refreshSingleLoraMetadata(filePath) { export async function fetchModelDescription(modelId, filePath) { try { - const response = await fetch(`/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`); + const response = await fetch(`/api/loras/model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`); if (!response.ok) { throw new Error(`Failed to fetch model description: ${response.statusText}`); diff --git a/static/js/components/ContextMenu/RecipeContextMenu.js b/static/js/components/ContextMenu/RecipeContextMenu.js index 63d5795a..f2862298 100644 --- a/static/js/components/ContextMenu/RecipeContextMenu.js +++ b/static/js/components/ContextMenu/RecipeContextMenu.js @@ -209,9 +209,9 @@ export class RecipeContextMenu extends BaseContextMenu { // Determine which endpoint to use based on available data if (lora.modelVersionId) { - endpoint = `/api/civitai/model/version/${lora.modelVersionId}`; + endpoint = `/api/loras/civitai/model/version/${lora.modelVersionId}`; } else if (lora.hash) { - endpoint = `/api/civitai/model/hash/${lora.hash}`; + endpoint = `/api/loras/civitai/model/hash/${lora.hash}`; } else { console.error("Missing both hash and modelVersionId for lora:", lora); return null; diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index ce98a11c..00ad0b0b 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -831,9 +831,9 @@ class RecipeModal { // Determine which endpoint to use based on available data if (lora.modelVersionId) { - endpoint = `/api/civitai/model/version/${lora.modelVersionId}`; + endpoint = `/api/loras/civitai/model/version/${lora.modelVersionId}`; } else if (lora.hash) { - endpoint = `/api/civitai/model/hash/${lora.hash}`; + endpoint = `/api/loras/civitai/model/hash/${lora.hash}`; } else { console.error("Missing both hash and modelVersionId for lora:", lora); return null; diff --git a/static/js/components/shared/ModelDescription.js b/static/js/components/shared/ModelDescription.js index 05acc224..64e78348 100644 --- a/static/js/components/shared/ModelDescription.js +++ b/static/js/components/shared/ModelDescription.js @@ -59,7 +59,7 @@ export async function loadModelDescription(modelId, filePath) { descriptionContainer.classList.add('hidden'); // Determine API endpoint based on file path or context - let apiEndpoint = `/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`; + let apiEndpoint = `/api/loras/model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`; // Try to get model description from API const response = await fetch(apiEndpoint); diff --git a/static/js/managers/MoveManager.js b/static/js/managers/MoveManager.js index e72c8274..50f735ae 100644 --- a/static/js/managers/MoveManager.js +++ b/static/js/managers/MoveManager.js @@ -219,7 +219,7 @@ class MoveManager { return; } - const response = await fetch('/api/move_model', { + const response = await fetch('/api/loras/move_model', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -257,7 +257,7 @@ class MoveManager { return; } - const response = await fetch('/api/move_models_bulk', { + const response = await fetch('/api/loras/move_models_bulk', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/web/comfyui/legacy_loras_widget.js b/web/comfyui/legacy_loras_widget.js index bec337da..bf2ba96f 100644 --- a/web/comfyui/legacy_loras_widget.js +++ b/web/comfyui/legacy_loras_widget.js @@ -147,7 +147,7 @@ export function addLorasWidget(node, name, opts, callback) { this.currentLora = loraName; // 获取预览URL - const response = await api.fetchApi(`/lora-preview-url?name=${encodeURIComponent(loraName)}`, { + const response = await api.fetchApi(`/loras/preview-url?name=${encodeURIComponent(loraName)}`, { method: 'GET' }); @@ -472,7 +472,7 @@ export function addLorasWidget(node, name, opts, callback) { try { // Get Civitai URL from API - const response = await api.fetchApi(`/lora-civitai-url?name=${encodeURIComponent(loraName)}`, { + const response = await api.fetchApi(`/loras/civitai-url?name=${encodeURIComponent(loraName)}`, { method: 'GET' }); diff --git a/web/comfyui/loras_widget_components.js b/web/comfyui/loras_widget_components.js index 54990d99..4a4448e0 100644 --- a/web/comfyui/loras_widget_components.js +++ b/web/comfyui/loras_widget_components.js @@ -165,7 +165,7 @@ export class PreviewTooltip { this.currentLora = loraName; // Get preview URL - const response = await api.fetchApi(`/lora-preview-url?name=${encodeURIComponent(loraName)}`, { + const response = await api.fetchApi(`/loras/preview-url?name=${encodeURIComponent(loraName)}`, { method: 'GET' }); diff --git a/web/comfyui/loras_widget_events.js b/web/comfyui/loras_widget_events.js index a876d0cd..0c1e56b4 100644 --- a/web/comfyui/loras_widget_events.js +++ b/web/comfyui/loras_widget_events.js @@ -265,7 +265,7 @@ export function createContextMenu(x, y, loraName, widget, previewTooltip, render try { // Get Civitai URL from API - const response = await api.fetchApi(`/lora-civitai-url?name=${encodeURIComponent(loraName)}`, { + const response = await api.fetchApi(`/loras/civitai-url?name=${encodeURIComponent(loraName)}`, { method: 'GET' }); diff --git a/web/comfyui/utils.js b/web/comfyui/utils.js index 54924103..5acf395a 100644 --- a/web/comfyui/utils.js +++ b/web/comfyui/utils.js @@ -174,7 +174,7 @@ export function collectActiveLorasFromChain(node, visited = new Set()) { export function updateConnectedTriggerWords(node, loraNames) { const connectedNodeIds = getConnectedTriggerToggleNodes(node); if (connectedNodeIds.length > 0) { - fetch("/loramanager/get_trigger_words", { + fetch("/api/loras/get_trigger_words", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ From d83fad6abce901f3b760dd80a4f90949b8295135 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Fri, 25 Jul 2025 10:04:18 +0800 Subject: [PATCH 02/20] Refactor API structure to unify model operations - Introduced MODEL_TYPES and MODEL_CONFIG for centralized model type management. - Created a unified API client for checkpoints and loras to streamline operations. - Updated all API calls in checkpointApi.js and loraApi.js to use the new client. - Simplified context menus and model card operations to leverage the unified API client. - Enhanced state management to accommodate new model types and their configurations. - Added virtual scrolling functions for recipes and improved loading states. - Refactored modal utilities to handle model exclusion and deletion generically. - Improved error handling and user feedback across various operations. --- py/utils/routes_common.py | 1 + static/js/api/apiConfig.js | 169 +++ static/js/api/baseModelApi.js | 1049 ++++++++--------- static/js/api/checkpointApi.js | 172 +-- static/js/api/loraApi.js | 185 +-- static/js/api/recipeApi.js | 96 +- static/js/checkpoints.js | 5 +- .../ContextMenu/CheckpointContextMenu.js | 13 +- static/js/components/shared/ModelCard.js | 23 +- static/js/components/shared/ModelMetadata.js | 33 +- .../components/shared/showcase/MediaUtils.js | 7 +- static/js/managers/MoveManager.js | 7 +- static/js/state/index.js | 41 +- static/js/utils/modalUtils.js | 27 +- static/js/utils/uiHelpers.js | 27 + 15 files changed, 927 insertions(+), 928 deletions(-) create mode 100644 static/js/api/apiConfig.js diff --git a/py/utils/routes_common.py b/py/utils/routes_common.py index 99f679cd..4e5c9807 100644 --- a/py/utils/routes_common.py +++ b/py/utils/routes_common.py @@ -1038,6 +1038,7 @@ class ModelRouteUtils: return web.json_response({ 'success': True, 'new_file_path': new_file_path, + 'new_preview_path': config.get_preview_static_url(new_preview), 'renamed_files': renamed_files, 'reload_required': False }) diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js new file mode 100644 index 00000000..6f02033f --- /dev/null +++ b/static/js/api/apiConfig.js @@ -0,0 +1,169 @@ +import { state } from '../state/index.js'; + +/** + * API Configuration + * Centralized configuration for all model types and their endpoints + */ + +// Model type definitions +export const MODEL_TYPES = { + LORA: 'loras', + CHECKPOINT: 'checkpoints', + EMBEDDING: 'embeddings' // Future model type +}; + +// Base API configuration for each model type +export const MODEL_CONFIG = { + [MODEL_TYPES.LORA]: { + displayName: 'LoRA', + singularName: 'lora', + defaultPageSize: 100, + supportsLetterFilter: true, + supportsBulkOperations: true, + supportsMove: true, + templateName: 'loras.html' + }, + [MODEL_TYPES.CHECKPOINT]: { + displayName: 'Checkpoint', + singularName: 'checkpoint', + defaultPageSize: 50, + supportsLetterFilter: false, + supportsBulkOperations: true, + supportsMove: false, + templateName: 'checkpoints.html' + }, + [MODEL_TYPES.EMBEDDING]: { + displayName: 'Embedding', + singularName: 'embedding', + defaultPageSize: 100, + supportsLetterFilter: true, + supportsBulkOperations: true, + supportsMove: true, + templateName: 'embeddings.html' + } +}; + +/** + * Generate API endpoints for a given model type + * @param {string} modelType - The model type (e.g., 'loras', 'checkpoints') + * @returns {Object} Object containing all API endpoints for the model type + */ +export function getApiEndpoints(modelType) { + if (!Object.values(MODEL_TYPES).includes(modelType)) { + throw new Error(`Invalid model type: ${modelType}`); + } + + return { + // Base CRUD operations + list: `/api/${modelType}`, + delete: `/api/${modelType}/delete`, + exclude: `/api/${modelType}/exclude`, + rename: `/api/${modelType}/rename`, + save: `/api/${modelType}/save-metadata`, + + // Bulk operations + bulkDelete: `/api/${modelType}/bulk-delete`, + + // CivitAI integration + fetchCivitai: `/api/${modelType}/fetch-civitai`, + fetchAllCivitai: `/api/${modelType}/fetch-all-civitai`, + relinkCivitai: `/api/${modelType}/relink-civitai`, + civitaiVersions: `/api/${modelType}/civitai/versions`, + + // Preview management + replacePreview: `/api/${modelType}/replace-preview`, + + // Query operations + scan: `/api/${modelType}/scan`, + topTags: `/api/${modelType}/top-tags`, + baseModels: `/api/${modelType}/base-models`, + roots: `/api/${modelType}/roots`, + folders: `/api/${modelType}/folders`, + duplicates: `/api/${modelType}/find-duplicates`, + conflicts: `/api/${modelType}/find-filename-conflicts`, + verify: `/api/${modelType}/verify-duplicates`, + + // Model-specific endpoints (will be merged with specific configs) + specific: {} + }; +} + +/** + * Model-specific endpoint configurations + */ +export const MODEL_SPECIFIC_ENDPOINTS = { + [MODEL_TYPES.LORA]: { + letterCounts: `/api/${MODEL_TYPES.LORA}/letter-counts`, + notes: `/api/${MODEL_TYPES.LORA}/get-notes`, + 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`, + moveModel: `/api/${MODEL_TYPES.LORA}/move_model`, + moveBulk: `/api/${MODEL_TYPES.LORA}/move_models_bulk`, + getTriggerWordsPost: `/api/${MODEL_TYPES.LORA}/get_trigger_words`, + civitaiModelByVersion: `/api/${MODEL_TYPES.LORA}/civitai/model/version`, + civitaiModelByHash: `/api/${MODEL_TYPES.LORA}/civitai/model/hash` + }, + [MODEL_TYPES.CHECKPOINT]: { + info: `/api/${MODEL_TYPES.CHECKPOINT}/info` + }, + [MODEL_TYPES.EMBEDDING]: { + // Future embedding-specific endpoints + } +}; + +/** + * Get complete API configuration for a model type + * @param {string} modelType - The model type + * @returns {Object} Complete API configuration + */ +export function getCompleteApiConfig(modelType) { + const baseEndpoints = getApiEndpoints(modelType); + const specificEndpoints = MODEL_SPECIFIC_ENDPOINTS[modelType] || {}; + const config = MODEL_CONFIG[modelType]; + + return { + modelType, + config, + endpoints: { + ...baseEndpoints, + specific: specificEndpoints + } + }; +} + +/** + * Validate if a model type is supported + * @param {string} modelType - The model type to validate + * @returns {boolean} True if valid, false otherwise + */ +export function isValidModelType(modelType) { + return Object.values(MODEL_TYPES).includes(modelType); +} + +/** + * Get model type from current page or explicit parameter + * @param {string} [explicitType] - Explicitly provided model type + * @returns {string} The model type + */ +export function getCurrentModelType(explicitType = null) { + if (explicitType && isValidModelType(explicitType)) { + return explicitType; + } + + return state.currentPageType || MODEL_TYPES.LORA; +} + +// Download API endpoints (shared across all model types) +export const DOWNLOAD_ENDPOINTS = { + download: '/api/download-model', + downloadGet: '/api/download-model-get', + cancelGet: '/api/cancel-download-get', + progress: '/api/download-progress' +}; + +// WebSocket endpoints +export const WS_ENDPOINTS = { + fetchProgress: '/ws/fetch-progress' +}; diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index fe915f86..116521ed 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1,45 +1,464 @@ import { state, getCurrentPageState } from '../state/index.js'; import { showToast } from '../utils/uiHelpers.js'; import { getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js'; +import { + getCompleteApiConfig, + getCurrentModelType, + isValidModelType, + DOWNLOAD_ENDPOINTS, + WS_ENDPOINTS +} from './apiConfig.js'; -// New method for virtual scrolling fetch -export async function fetchModelsPage(options = {}) { - const { - modelType = 'lora', - page = 1, - pageSize = 100, - endpoint = '/api/loras' - } = options; +/** + * Universal API client for all model types + */ +class ModelApiClient { + constructor(modelType = null) { + this.modelType = modelType || getCurrentModelType(); + this.apiConfig = getCompleteApiConfig(this.modelType); + } - const pageState = getCurrentPageState(); - - try { - const params = new URLSearchParams({ - page: page, - page_size: pageSize || pageState.pageSize || 20, - sort_by: pageState.sortBy - }); + /** + * Set the model type for this client instance + * @param {string} modelType - The model type to use + */ + setModelType(modelType) { + if (!isValidModelType(modelType)) { + throw new Error(`Invalid model type: ${modelType}`); + } + this.modelType = modelType; + this.apiConfig = getCompleteApiConfig(modelType); + } + + /** + * Get the current page state for this model type + */ + getPageState() { + const currentType = state.currentPageType; + // Temporarily switch to get the right page state + state.currentPageType = this.modelType; + const pageState = getCurrentPageState(); + state.currentPageType = currentType; // Restore + return pageState; + } + + /** + * Fetch models with pagination + */ + async fetchModelsPage(page = 1, pageSize = null) { + const pageState = this.getPageState(); + const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize; + try { + const params = this._buildQueryParams({ + page, + page_size: actualPageSize, + sort_by: pageState.sortBy + }, pageState); + + const response = await fetch(`${this.apiConfig.endpoints.list}?${params}`); + if (!response.ok) { + throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`); + } + + const data = await response.json(); + + return { + items: data.items, + totalItems: data.total, + totalPages: data.total_pages, + currentPage: page, + hasMore: page < data.total_pages, + folders: data.folders + }; + + } catch (error) { + console.error(`Error fetching ${this.apiConfig.config.displayName}s:`, error); + showToast(`Failed to fetch ${this.apiConfig.config.displayName}s: ${error.message}`, 'error'); + throw error; + } + } + + /** + * Delete a model + */ + async deleteModel(filePath) { + try { + state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.singularName}...`); + + const response = await fetch(this.apiConfig.endpoints.delete, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_path: filePath }) + }); + + if (!response.ok) { + throw new Error(`Failed to delete ${this.apiConfig.config.singularName}: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.success) { + if (state.virtualScroller) { + state.virtualScroller.removeItemByFilePath(filePath); + } + showToast(`${this.apiConfig.config.displayName} deleted successfully`, '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'); + return false; + } finally { + state.loadingManager.hide(); + } + } + + /** + * Exclude a model + */ + async excludeModel(filePath) { + try { + state.loadingManager.showSimpleLoading(`Excluding ${this.apiConfig.config.singularName}...`); + + const response = await fetch(this.apiConfig.endpoints.exclude, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_path: filePath }) + }); + + if (!response.ok) { + throw new Error(`Failed to exclude ${this.apiConfig.config.singularName}: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.success) { + if (state.virtualScroller) { + state.virtualScroller.removeItemByFilePath(filePath); + } + showToast(`${this.apiConfig.config.displayName} excluded successfully`, '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'); + return false; + } finally { + state.loadingManager.hide(); + } + } + + /** + * Rename a model file + */ + async renameModelFile(filePath, newFileName) { + try { + state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`); + + const response = await fetch(this.apiConfig.endpoints.rename, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file_path: filePath, + new_file_name: newFileName + }) + }); + + const result = await response.json(); + + if (result.success) { + state.virtualScroller.updateSingleItem(filePath, { + file_name: newFileName, + file_path: result.new_file_path, + preview_url: result.new_preview_path + }); + + showToast('File name updated successfully', 'success'); + } else { + showToast('Failed to rename file: ' + (result.error || 'Unknown error'), 'error'); + } + + return result; + } catch (error) { + console.error(`Error renaming ${this.apiConfig.config.singularName} file:`, error); + throw error; + } finally { + state.loadingManager.hide(); + } + } + + /** + * Replace model preview + */ + replaceModelPreview(filePath) { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*,video/mp4'; + + input.onchange = async () => { + if (!input.files || !input.files[0]) return; + + const file = input.files[0]; + await this.uploadPreview(filePath, file); + }; + + input.click(); + } + + /** + * Upload preview image + */ + async uploadPreview(filePath, file, nsfwLevel = 0) { + try { + state.loadingManager.showSimpleLoading('Uploading preview...'); + + const formData = new FormData(); + formData.append('preview_file', file); + formData.append('model_path', filePath); + formData.append('nsfw_level', nsfwLevel.toString()); + + const response = await fetch(this.apiConfig.endpoints.replacePreview, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + throw new Error('Upload failed'); + } + + const data = await response.json(); + const pageState = this.getPageState(); + + // Update the version timestamp + const timestamp = Date.now(); + if (pageState.previewVersions) { + pageState.previewVersions.set(filePath, timestamp); + + const storageKey = `${this.modelType}_preview_versions`; + saveMapToStorage(storageKey, pageState.previewVersions); + } + + const updateData = { + preview_url: data.preview_url, + preview_nsfw_level: data.preview_nsfw_level + }; + + state.virtualScroller.updateSingleItem(filePath, updateData); + showToast('Preview updated successfully', 'success'); + } catch (error) { + console.error('Error uploading preview:', error); + showToast('Failed to upload preview image', 'error'); + } finally { + state.loadingManager.hide(); + } + } + + /** + * Save model metadata + */ + async saveModelMetadata(filePath, data) { + try { + state.loadingManager.showSimpleLoading('Saving metadata...'); + + const response = await fetch(this.apiConfig.endpoints.save, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file_path: filePath, + ...data + }) + }); + + if (!response.ok) { + throw new Error('Failed to save metadata'); + } + + state.virtualScroller.updateSingleItem(filePath, data); + return response.json(); + } finally { + state.loadingManager.hide(); + } + } + + /** + * Refresh models (scan) + */ + async refreshModels(fullRebuild = false) { + try { + state.loadingManager.showSimpleLoading( + `${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...` + ); + + const url = new URL(this.apiConfig.endpoints.scan, window.location.origin); + url.searchParams.append('full_rebuild', fullRebuild); + + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`); + } + + showToast(`${fullRebuild ? 'Full rebuild' : 'Refresh'} complete`, 'success'); + } catch (error) { + console.error('Refresh failed:', error); + showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${this.apiConfig.config.displayName}s`, 'error'); + } finally { + state.loadingManager.hide(); + state.loadingManager.restoreProgressBar(); + } + } + + /** + * Fetch CivitAI metadata for single model + */ + async refreshSingleModelMetadata(filePath) { + try { + state.loadingManager.showSimpleLoading('Refreshing metadata...'); + + const response = await fetch(this.apiConfig.endpoints.fetchCivitai, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_path: filePath }) + }); + + if (!response.ok) { + throw new Error('Failed to refresh metadata'); + } + + const data = await response.json(); + + if (data.success) { + if (data.metadata && state.virtualScroller) { + state.virtualScroller.updateSingleItem(filePath, data.metadata); + } + + showToast('Metadata refreshed successfully', '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'); + return false; + } finally { + state.loadingManager.hide(); + state.loadingManager.restoreProgressBar(); + } + } + + /** + * Fetch CivitAI metadata for all models + */ + async fetchCivitaiMetadata(resetAndReloadFunction) { + let ws = null; + + await state.loadingManager.showWithProgress(async (loading) => { + try { + const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; + ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`); + + const operationComplete = new Promise((resolve, reject) => { + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + switch(data.status) { + case 'started': + loading.setStatus('Starting metadata fetch...'); + break; + + case 'processing': + const percent = ((data.processed / data.total) * 100).toFixed(1); + loading.setProgress(percent); + loading.setStatus( + `Processing (${data.processed}/${data.total}) ${data.current_name}` + ); + break; + + case 'completed': + loading.setProgress(100); + loading.setStatus( + `Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s` + ); + resolve(); + break; + + case 'error': + reject(new Error(data.error)); + break; + } + }; + + ws.onerror = (error) => { + reject(new Error('WebSocket error: ' + error.message)); + }; + }); + + await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = reject; + }); + + const response = await fetch(this.apiConfig.endpoints.fetchAllCivitai, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + + if (!response.ok) { + throw new Error('Failed to fetch metadata'); + } + + await operationComplete; + + if (typeof resetAndReloadFunction === 'function') { + await resetAndReloadFunction(); + } + + } catch (error) { + console.error('Error fetching metadata:', error); + showToast('Failed to fetch metadata: ' + error.message, 'error'); + } finally { + if (ws) { + ws.close(); + } + } + }, { + initialMessage: 'Connecting...', + completionMessage: 'Metadata update complete' + }); + } + + /** + * Build query parameters for API requests + */ + _buildQueryParams(baseParams, pageState) { + const params = new URLSearchParams(baseParams); + + // Add common parameters if (pageState.activeFolder !== null) { params.append('folder', pageState.activeFolder); } - // Add favorites filter parameter if enabled if (pageState.showFavoritesOnly) { params.append('favorites_only', 'true'); } - // Add active letter filter if set - if (pageState.activeLetterFilter) { + // Add letter filter for supported model types + if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) { params.append('first_letter', pageState.activeLetterFilter); } - // Add search parameters if there's a search term + // Add search parameters if (pageState.filters?.search) { params.append('search', pageState.filters.search); params.append('fuzzy', 'true'); - // Add search option parameters if available if (pageState.searchOptions) { params.append('search_filename', pageState.searchOptions.filename.toString()); params.append('search_modelname', pageState.searchOptions.modelname.toString()); @@ -50,16 +469,14 @@ export async function fetchModelsPage(options = {}) { } } - // Add filter parameters if active + // Add filter parameters if (pageState.filters) { - // Handle tags filters if (pageState.filters.tags && pageState.filters.tags.length > 0) { pageState.filters.tags.forEach(tag => { params.append('tag', tag); }); } - // Handle base model filters if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) { pageState.filters.baseModel.forEach(model => { params.append('base_model', model); @@ -68,17 +485,23 @@ export async function fetchModelsPage(options = {}) { } // Add model-specific parameters - if (modelType === 'lora') { - // Check for recipe-based filtering parameters from session storage + this._addModelSpecificParams(params, pageState); + + return params; + } + + /** + * Add model-specific parameters to query + */ + _addModelSpecificParams(params, pageState) { + // Override in specific implementations or handle via configuration + if (this.modelType === 'loras') { const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash'); const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes'); - // Add hash filter parameter if present if (filterLoraHash) { params.append('lora_hash', filterLoraHash); - } - // Add multiple hashes filter if present - else if (filterLoraHashes) { + } else if (filterLoraHashes) { try { if (Array.isArray(filterLoraHashes) && filterLoraHashes.length > 0) { params.append('lora_hashes', filterLoraHashes.join(',')); @@ -88,546 +511,64 @@ export async function fetchModelsPage(options = {}) { } } } - - const response = await fetch(`${endpoint}?${params}`); - if (!response.ok) { - throw new Error(`Failed to fetch models: ${response.statusText}`); - } - - const data = await response.json(); - - return { - items: data.items, - totalItems: data.total, - totalPages: data.total_pages, - currentPage: page, - hasMore: page < data.total_pages, - folders: data.folders - }; - - } catch (error) { - console.error(`Error fetching ${modelType}s:`, error); - showToast(`Failed to fetch ${modelType}s: ${error.message}`, 'error'); - throw error; } } -/** - * Reset and reload models using virtual scrolling - * @param {Object} options - Operation options - * @returns {Promise} The fetch result - */ -export async function resetAndReloadWithVirtualScroll(options = {}) { - const { - modelType = 'lora', - updateFolders = false, - fetchPageFunction - } = options; - - const pageState = getCurrentPageState(); - - try { - pageState.isLoading = true; - - // Reset page counter - pageState.currentPage = 1; - - // Fetch the first page - const result = await fetchPageFunction(1, pageState.pageSize || 50); - - // Update the virtual scroller - state.virtualScroller.refreshWithData( - result.items, - result.totalItems, - result.hasMore - ); - - // Update state - pageState.hasMore = result.hasMore; - pageState.currentPage = 2; // Next page will be 2 - - // Update folders if needed - if (updateFolders && result.folders) { - updateFolderTags(result.folders); - } - - return result; - } catch (error) { - console.error(`Error reloading ${modelType}s:`, error); - showToast(`Failed to reload ${modelType}s: ${error.message}`, 'error'); - throw error; - } finally { - pageState.isLoading = false; +// Export factory functions and utilities +export function createModelApiClient(modelType = null) { + return new ModelApiClient(modelType); +} + +let _singletonClient = null; + +export function getModelApiClient() { + if (!_singletonClient) { + _singletonClient = new ModelApiClient(); } + _singletonClient.setModelType(state.currentPageType); + return _singletonClient; } -/** - * Load more models using virtual scrolling - * @param {Object} options - Operation options - * @returns {Promise} The fetch result - */ -export async function loadMoreWithVirtualScroll(options = {}) { - const { - modelType = 'lora', - resetPage = false, - updateFolders = false, - fetchPageFunction - } = options; - - const pageState = getCurrentPageState(); - - try { - // Start loading state - pageState.isLoading = true; - - // Reset to first page if requested - if (resetPage) { - pageState.currentPage = 1; - } - - // Fetch the first page of data - const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50); - - // Update virtual scroller with the new data - state.virtualScroller.refreshWithData( - result.items, - result.totalItems, - result.hasMore - ); - - // Update state - pageState.hasMore = result.hasMore; - pageState.currentPage = 2; // Next page to load would be 2 - - // Update folders if needed - if (updateFolders && result.folders) { - updateFolderTags(result.folders); - } - - return result; - } catch (error) { - console.error(`Error loading ${modelType}s:`, error); - showToast(`Failed to load ${modelType}s: ${error.message}`, 'error'); - throw error; - } finally { - pageState.isLoading = false; - } +// Legacy compatibility exports +export async function fetchModelsPage(options = {}) { + const { modelType = getCurrentModelType(), ...rest } = options; + const client = createModelApiClient(modelType); + return client.fetchModelsPage(rest.page, rest.pageSize); } -// Update folder tags in the UI -export function updateFolderTags(folders) { - const folderTagsContainer = document.querySelector('.folder-tags'); - if (!folderTagsContainer) return; - - // Keep track of currently selected folder - const pageState = getCurrentPageState(); - const currentFolder = pageState.activeFolder; - - // Create HTML for folder tags - const tagsHTML = folders.map(folder => { - const isActive = folder === currentFolder; - return `
${folder}
`; - }).join(''); - - // Update the container - folderTagsContainer.innerHTML = tagsHTML; - - // Reattach click handlers and ensure the active tag is visible - const tags = folderTagsContainer.querySelectorAll('.tag'); - tags.forEach(tag => { - if (typeof toggleFolder === 'function') { - tag.addEventListener('click', toggleFolder); - } - if (tag.dataset.folder === currentFolder) { - tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } - }); +export async function deleteModel(filePath, modelType = null) { + const client = createModelApiClient(modelType); + return client.deleteModel(filePath); } -// Generic function to replace a model preview -export function replaceModelPreview(filePath, modelType = 'lora') { - // Open file picker - const input = document.createElement('input'); - input.type = 'file'; - input.accept ='image/*,video/mp4'; - - input.onchange = async function() { - if (!input.files || !input.files[0]) return; - - const file = input.files[0]; - await uploadPreview(filePath, file, modelType); - }; - - input.click(); +export async function excludeModel(filePath, modelType = null) { + const client = createModelApiClient(modelType); + return client.excludeModel(filePath); } -// Delete a model (generic) -export async function deleteModel(filePath, modelType = 'lora') { - try { - state.loadingManager.showSimpleLoading(`Deleting ${modelType}...`); - - const endpoint = modelType === 'checkpoint' - ? '/api/checkpoints/delete' - : '/api/loras/delete'; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath - }) - }); - - if (!response.ok) { - throw new Error(`Failed to delete ${modelType}: ${response.statusText}`); - } - - const data = await response.json(); - - if (data.success) { - // If virtual scroller exists, update its data - if (state.virtualScroller) { - state.virtualScroller.removeItemByFilePath(filePath); - } else { - // Legacy approach: remove the card from UI directly - const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); - if (card) { - card.remove(); - } - } - - showToast(`${modelType} deleted successfully`, 'success'); - return true; - } else { - throw new Error(data.error || `Failed to delete ${modelType}`); - } - } catch (error) { - console.error(`Error deleting ${modelType}:`, error); - showToast(`Failed to delete ${modelType}: ${error.message}`, 'error'); - return false; - } finally { - state.loadingManager.hide(); - } +export async function renameModelFile(filePath, newFileName, modelType = null) { + const client = createModelApiClient(modelType); + return client.renameModelFile(filePath, newFileName); +} + +export async function replaceModelPreview(filePath, modelType = null) { + const client = createModelApiClient(modelType); + return client.replaceModelPreview(filePath); } -// Generic function to refresh models export async function refreshModels(options = {}) { - const { - modelType = 'lora', - scanEndpoint = '/api/loras/scan', - resetAndReloadFunction, - fullRebuild = false // New parameter with default value false - } = options; - - try { - state.loadingManager.showSimpleLoading(`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${modelType}s...`); - - // Add fullRebuild parameter to the request - const url = new URL(scanEndpoint, window.location.origin); - url.searchParams.append('full_rebuild', fullRebuild); - - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Failed to refresh ${modelType}s: ${response.status} ${response.statusText}`); - } - - if (typeof resetAndReloadFunction === 'function') { - await resetAndReloadFunction(true); // update folders - } - - showToast(`${fullRebuild ? 'Full rebuild' : 'Refresh'} complete`, 'success'); - } catch (error) { - console.error(`Refresh failed:`, error); - showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${modelType}s`, 'error'); - } finally { - state.loadingManager.hide(); - state.loadingManager.restoreProgressBar(); - } + const { modelType = getCurrentModelType(), fullRebuild = false } = options; + const client = createModelApiClient(modelType); + return client.refreshModels(fullRebuild); +} + +export async function refreshSingleModelMetadata(filePath, modelType = null) { + const client = createModelApiClient(modelType); + return client.refreshSingleModelMetadata(filePath); } -// Generic fetch from Civitai export async function fetchCivitaiMetadata(options = {}) { - const { - modelType = 'lora', - fetchEndpoint = '/api/fetch-all-civitai', - resetAndReloadFunction - } = options; - - let ws = null; - - await state.loadingManager.showWithProgress(async (loading) => { - try { - const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; - ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`); - - const operationComplete = new Promise((resolve, reject) => { - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - - switch(data.status) { - case 'started': - loading.setStatus('Starting metadata fetch...'); - break; - - case 'processing': - const percent = ((data.processed / data.total) * 100).toFixed(1); - loading.setProgress(percent); - loading.setStatus( - `Processing (${data.processed}/${data.total}) ${data.current_name}` - ); - break; - - case 'completed': - loading.setProgress(100); - loading.setStatus( - `Completed: Updated ${data.success} of ${data.processed} ${modelType}s` - ); - resolve(); - break; - - case 'error': - reject(new Error(data.error)); - break; - } - }; - - ws.onerror = (error) => { - reject(new Error('WebSocket error: ' + error.message)); - }; - }); - - await new Promise((resolve, reject) => { - ws.onopen = resolve; - ws.onerror = reject; - }); - - const requestBody = modelType === 'checkpoint' - ? JSON.stringify({ model_type: 'checkpoint' }) - : JSON.stringify({}); - - const response = await fetch(fetchEndpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: requestBody - }); - - if (!response.ok) { - throw new Error('Failed to fetch metadata'); - } - - await operationComplete; - - if (typeof resetAndReloadFunction === 'function') { - await resetAndReloadFunction(); - } - - } catch (error) { - console.error('Error fetching metadata:', error); - showToast('Failed to fetch metadata: ' + error.message, 'error'); - } finally { - if (ws) { - ws.close(); - } - } - }, { - initialMessage: 'Connecting...', - completionMessage: 'Metadata update complete' - }); -} - -// Generic function to refresh single model metadata -export async function refreshSingleModelMetadata(filePath, modelType = 'lora') { - try { - state.loadingManager.showSimpleLoading('Refreshing metadata...'); - - const endpoint = modelType === 'checkpoint' - ? '/api/checkpoints/fetch-civitai' - : '/api/loras/fetch-civitai'; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ file_path: filePath }) - }); - - if (!response.ok) { - throw new Error('Failed to refresh metadata'); - } - - const data = await response.json(); - - if (data.success) { - // Use the returned metadata to update just this single item - if (data.metadata && state.virtualScroller) { - state.virtualScroller.updateSingleItem(filePath, data.metadata); - } - - showToast('Metadata refreshed successfully', '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'); - return false; - } finally { - state.loadingManager.hide(); - state.loadingManager.restoreProgressBar(); - } -} - -// Generic function to exclude a model -export async function excludeModel(filePath, modelType = 'lora') { - try { - state.loadingManager.showSimpleLoading(`Excluding ${modelType}...`); - - const endpoint = modelType === 'checkpoint' - ? '/api/checkpoints/exclude' - : '/api/loras/exclude'; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath - }) - }); - - if (!response.ok) { - throw new Error(`Failed to exclude ${modelType}: ${response.statusText}`); - } - - const data = await response.json(); - - if (data.success) { - // If virtual scroller exists, update its data - if (state.virtualScroller) { - state.virtualScroller.removeItemByFilePath(filePath); - } else { - // Legacy approach: remove the card from UI directly - const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); - if (card) { - card.remove(); - } - } - - showToast(`${modelType} excluded successfully`, 'success'); - return true; - } else { - throw new Error(data.error || `Failed to exclude ${modelType}`); - } - } catch (error) { - console.error(`Error excluding ${modelType}:`, error); - showToast(`Failed to exclude ${modelType}: ${error.message}`, 'error'); - return false; - } finally { - state.loadingManager.hide(); - } -} - -// Upload a preview image -export async function uploadPreview(filePath, file, modelType = 'lora', nsfwLevel = 0) { - try { - state.loadingManager.showSimpleLoading('Uploading preview...'); - - const formData = new FormData(); - - // Prepare common form data - formData.append('preview_file', file); - formData.append('model_path', filePath); - formData.append('nsfw_level', nsfwLevel.toString()); // Add nsfw_level parameter - - // Set endpoint based on model type - const endpoint = modelType === 'checkpoint' - ? '/api/checkpoints/replace-preview' - : '/api/loras/replace_preview'; - - const response = await fetch(endpoint, { - method: 'POST', - body: formData - }); - - if (!response.ok) { - throw new Error('Upload failed'); - } - - const data = await response.json(); - - // Get the current page's previewVersions Map based on model type - const pageType = modelType === 'checkpoint' ? 'checkpoints' : 'loras'; - const previewVersions = state.pages[pageType].previewVersions; - - // Update the version timestamp - const timestamp = Date.now(); - if (previewVersions) { - previewVersions.set(filePath, timestamp); - - // Save the updated Map to localStorage - const storageKey = modelType === 'checkpoint' ? 'checkpoint_preview_versions' : 'lora_preview_versions'; - saveMapToStorage(storageKey, previewVersions); - } - - const updateData = { - preview_url: data.preview_url, - preview_nsfw_level: data.preview_nsfw_level // Include nsfw level in update data - }; - - state.virtualScroller.updateSingleItem(filePath, updateData); - - showToast('Preview updated successfully', 'success'); - } catch (error) { - console.error('Error uploading preview:', error); - showToast('Failed to upload preview image', 'error'); - } finally { - state.loadingManager.hide(); - } -} - -// Private methods - -// Private function to perform the delete operation -async function performDelete(filePath, modelType = 'lora') { - try { - showToast(`Deleting ${modelType}...`, 'info'); - - const response = await fetch('/api/model/delete', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - file_path: filePath, - model_type: modelType - }) - }); - - if (!response.ok) { - throw new Error(`Failed to delete ${modelType}: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - - if (data.success) { - // Remove the card from UI - const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); - if (card) { - card.remove(); - } - - showToast(`${modelType} deleted successfully`, 'success'); - } else { - throw new Error(data.error || `Failed to delete ${modelType}`); - } - } catch (error) { - console.error(`Error deleting ${modelType}:`, error); - showToast(`Failed to delete ${modelType}: ${error.message}`, 'error'); - } + const { modelType = getCurrentModelType(), resetAndReloadFunction } = options; + const client = createModelApiClient(modelType); + return client.fetchCivitaiMetadata(resetAndReloadFunction); } \ No newline at end of file diff --git a/static/js/api/checkpointApi.js b/static/js/api/checkpointApi.js index a595b2aa..574572a1 100644 --- a/static/js/api/checkpointApi.js +++ b/static/js/api/checkpointApi.js @@ -1,165 +1,43 @@ -import { - fetchModelsPage, - resetAndReloadWithVirtualScroll, - loadMoreWithVirtualScroll, - refreshModels as baseRefreshModels, - deleteModel as baseDeleteModel, - replaceModelPreview, - fetchCivitaiMetadata, - refreshSingleModelMetadata, - excludeModel as baseExcludeModel -} from './baseModelApi.js'; -import { state } from '../state/index.js'; +import { createModelApiClient } from './baseModelApi.js'; +import { MODEL_TYPES } from './apiConfig.js'; -/** - * Fetch checkpoints with pagination for virtual scrolling - * @param {number} page - Page number to fetch - * @param {number} pageSize - Number of items per page - * @returns {Promise} Object containing items, total count, and pagination info - */ -export async function fetchCheckpointsPage(page = 1, pageSize = 100) { - return fetchModelsPage({ - modelType: 'checkpoint', - page, - pageSize, - endpoint: '/api/checkpoints' - }); -} +// Create Checkpoint-specific API client +const checkpointApiClient = createModelApiClient(MODEL_TYPES.CHECKPOINT); -/** - * Load more checkpoints with pagination - updated to work with VirtualScroller - * @param {boolean} resetPage - Whether to reset to the first page - * @param {boolean} updateFolders - Whether to update folder tags - * @returns {Promise} - */ +// Export all common operations using the unified client +export const deleteModel = (filePath) => checkpointApiClient.deleteModel(filePath); +export const excludeCheckpoint = (filePath) => checkpointApiClient.excludeModel(filePath); +export const renameCheckpointFile = (filePath, newFileName) => checkpointApiClient.renameModelFile(filePath, newFileName); +export const replacePreview = (filePath) => checkpointApiClient.replaceModelPreview(filePath); +export const saveModelMetadata = (filePath, data) => checkpointApiClient.saveModelMetadata(filePath, data); +export const refreshCheckpoints = (fullRebuild = false) => checkpointApiClient.refreshModels(fullRebuild); +export const refreshSingleCheckpointMetadata = (filePath) => checkpointApiClient.refreshSingleModelMetadata(filePath); +export const fetchCivitai = (resetAndReloadFunction) => checkpointApiClient.fetchCivitaiMetadata(resetAndReloadFunction); + +// Pagination functions +export const fetchCheckpointsPage = (page = 1, pageSize = 50) => checkpointApiClient.fetchModelsPage(page, pageSize); + +// Virtual scrolling operations export async function loadMoreCheckpoints(resetPage = false, updateFolders = false) { - return loadMoreWithVirtualScroll({ - modelType: 'checkpoint', - resetPage, - updateFolders, - fetchPageFunction: fetchCheckpointsPage - }); + return checkpointApiClient.loadMoreWithVirtualScroll(resetPage, updateFolders); } -// Reset and reload checkpoints export async function resetAndReload(updateFolders = false) { - return resetAndReloadWithVirtualScroll({ - modelType: 'checkpoint', - updateFolders, - fetchPageFunction: fetchCheckpointsPage - }); + return checkpointApiClient.resetAndReloadWithVirtualScroll(updateFolders); } -// Refresh checkpoints -export async function refreshCheckpoints(fullRebuild = false) { - return baseRefreshModels({ - modelType: 'checkpoint', - scanEndpoint: '/api/checkpoints/scan', - resetAndReloadFunction: resetAndReload, - fullRebuild: fullRebuild - }); -} - -// Delete a checkpoint -export function deleteCheckpoint(filePath) { - return baseDeleteModel(filePath, 'checkpoint'); -} - -// Replace checkpoint preview -export function replaceCheckpointPreview(filePath) { - return replaceModelPreview(filePath, 'checkpoint'); -} - -// Fetch metadata from Civitai for checkpoints -export async function fetchCivitai() { - return fetchCivitaiMetadata({ - modelType: 'checkpoint', - fetchEndpoint: '/api/checkpoints/fetch-all-civitai', - resetAndReloadFunction: resetAndReload - }); -} - -// Refresh single checkpoint metadata -export async function refreshSingleCheckpointMetadata(filePath) { - await refreshSingleModelMetadata(filePath, 'checkpoint'); -} - -/** - * Save model metadata to the server - * @param {string} filePath - Path to the model file - * @param {Object} data - Metadata to save - * @returns {Promise} - Promise that resolves with the server response - */ -export async function saveModelMetadata(filePath, data) { +// Checkpoint-specific functions +export async function getCheckpointInfo(name) { try { - // Show loading indicator - state.loadingManager.showSimpleLoading('Saving metadata...'); - - const response = await fetch('/api/checkpoints/save-metadata', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath, - ...data - }) - }); - - if (!response.ok) { - throw new Error('Failed to save metadata'); - } - - // Update the virtual scroller with the new metadata - state.virtualScroller.updateSingleItem(filePath, data); - - return response.json(); - } finally { - // Always hide the loading indicator when done - state.loadingManager.hide(); - } -} - -/** - * Exclude a checkpoint model from being shown in the UI - * @param {string} filePath - File path of the checkpoint to exclude - * @returns {Promise} Promise resolving to success status - */ -export function excludeCheckpoint(filePath) { - return baseExcludeModel(filePath, 'checkpoint'); -} - -/** - * Rename a checkpoint file - * @param {string} filePath - Current file path - * @param {string} newFileName - New file name (without path) - * @returns {Promise} - Promise that resolves with the server response - */ -export async function renameCheckpointFile(filePath, newFileName) { - try { - // Show loading indicator - state.loadingManager.showSimpleLoading('Renaming checkpoint file...'); - - const response = await fetch('/api/checkpoints/rename', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath, - new_file_name: newFileName - }) - }); + const response = await fetch(`${checkpointApiClient.apiConfig.endpoints.specific.info}/${encodeURIComponent(name)}`); if (!response.ok) { - throw new Error(`Server returned ${response.status}: ${response.statusText}`); + throw new Error(`Failed to fetch checkpoint info: ${response.statusText}`); } return await response.json(); } catch (error) { - console.error('Error renaming checkpoint file:', error); + console.error('Error fetching checkpoint info:', error); throw error; - } finally { - state.loadingManager.hide(); } } \ No newline at end of file diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js index a7f0bccd..738571fb 100644 --- a/static/js/api/loraApi.js +++ b/static/js/api/loraApi.js @@ -1,131 +1,35 @@ -import { - fetchModelsPage, - resetAndReloadWithVirtualScroll, - loadMoreWithVirtualScroll, - refreshModels as baseRefreshModels, - deleteModel as baseDeleteModel, - replaceModelPreview, - fetchCivitaiMetadata, - refreshSingleModelMetadata, - excludeModel as baseExcludeModel -} from './baseModelApi.js'; -import { state } from '../state/index.js'; +import { createModelApiClient } from './baseModelApi.js'; +import { MODEL_TYPES } from './apiConfig.js'; -/** - * Save model metadata to the server - * @param {string} filePath - File path - * @param {Object} data - Data to save - * @returns {Promise} Promise of the save operation - */ -export async function saveModelMetadata(filePath, data) { - try { - // Show loading indicator - state.loadingManager.showSimpleLoading('Saving metadata...'); - - const response = await fetch('/api/loras/save-metadata', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath, - ...data - }) - }); +// Create LoRA-specific API client +const loraApiClient = createModelApiClient(MODEL_TYPES.LORA); - if (!response.ok) { - throw new Error('Failed to save metadata'); - } +// Export all common operations using the unified client +export const deleteModel = (filePath) => loraApiClient.deleteModel(filePath); +export const excludeLora = (filePath) => loraApiClient.excludeModel(filePath); +export const renameLoraFile = (filePath, newFileName) => loraApiClient.renameModelFile(filePath, newFileName); +export const replacePreview = (filePath) => loraApiClient.replaceModelPreview(filePath); +export const saveModelMetadata = (filePath, data) => loraApiClient.saveModelMetadata(filePath, data); +export const refreshLoras = (fullRebuild = false) => loraApiClient.refreshModels(fullRebuild); +export const refreshSingleLoraMetadata = (filePath) => loraApiClient.refreshSingleModelMetadata(filePath); +export const fetchCivitai = (resetAndReloadFunction) => loraApiClient.fetchCivitaiMetadata(resetAndReloadFunction); - // Update the virtual scroller with the new data - state.virtualScroller.updateSingleItem(filePath, data); - - return response.json(); - } finally { - // Always hide the loading indicator when done - state.loadingManager.hide(); - } -} +// Pagination functions +export const fetchLorasPage = (page = 1, pageSize = 100) => loraApiClient.fetchModelsPage(page, pageSize); -/** - * Exclude a lora model from being shown in the UI - * @param {string} filePath - File path of the model to exclude - * @returns {Promise} Promise resolving to success status - */ -export async function excludeLora(filePath) { - return baseExcludeModel(filePath, 'lora'); -} - -/** - * Load more loras with pagination - updated to work with VirtualScroller - * @param {boolean} resetPage - Whether to reset to the first page - * @param {boolean} updateFolders - Whether to update folder tags - * @returns {Promise} - */ +// Virtual scrolling operations export async function loadMoreLoras(resetPage = false, updateFolders = false) { - return loadMoreWithVirtualScroll({ - modelType: 'lora', - resetPage, - updateFolders, - fetchPageFunction: fetchLorasPage - }); -} - -/** - * Fetch loras with pagination for virtual scrolling - * @param {number} page - Page number to fetch - * @param {number} pageSize - Number of items per page - * @returns {Promise} Object containing items, total count, and pagination info - */ -export async function fetchLorasPage(page = 1, pageSize = 100) { - return fetchModelsPage({ - modelType: 'lora', - page, - pageSize, - endpoint: '/api/loras' - }); -} - -export async function fetchCivitai() { - return fetchCivitaiMetadata({ - modelType: 'lora', - fetchEndpoint: '/api/loras/fetch-all-civitai', - resetAndReloadFunction: resetAndReload - }); -} - -export async function deleteModel(filePath) { - return baseDeleteModel(filePath, 'lora'); -} - -export async function replacePreview(filePath) { - return replaceModelPreview(filePath, 'lora'); + return loraApiClient.loadMoreWithVirtualScroll(resetPage, updateFolders); } export async function resetAndReload(updateFolders = false) { - return resetAndReloadWithVirtualScroll({ - modelType: 'lora', - updateFolders, - fetchPageFunction: fetchLorasPage - }); -} - -export async function refreshLoras(fullRebuild = false) { - return baseRefreshModels({ - modelType: 'lora', - scanEndpoint: '/api/loras/scan', - resetAndReloadFunction: resetAndReload, - fullRebuild: fullRebuild - }); -} - -export async function refreshSingleLoraMetadata(filePath) { - await refreshSingleModelMetadata(filePath, 'lora'); + return loraApiClient.resetAndReloadWithVirtualScroll(updateFolders); } +// LoRA-specific functions that don't have common equivalents export async function fetchModelDescription(modelId, filePath) { try { - const response = await fetch(`/api/loras/model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`); + const response = await fetch(`${loraApiClient.apiConfig.endpoints.specific.modelDescription}?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`); if (!response.ok) { throw new Error(`Failed to fetch model description: ${response.statusText}`); @@ -138,38 +42,47 @@ export async function fetchModelDescription(modelId, filePath) { } } -/** - * Rename a LoRA file - * @param {string} filePath - Current file path - * @param {string} newFileName - New file name (without path) - * @returns {Promise} - Promise that resolves with the server response - */ -export async function renameLoraFile(filePath, newFileName) { +// Move operations (LoRA-specific) +export async function moveModel(filePath, targetPath) { try { - // Show loading indicator - state.loadingManager.showSimpleLoading('Renaming LoRA file...'); - - const response = await fetch('/api/loras/rename', { + const response = await fetch(loraApiClient.apiConfig.endpoints.specific.moveModel, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file_path: filePath, - new_file_name: newFileName + target_path: targetPath }) }); if (!response.ok) { - throw new Error(`Server returned ${response.status}: ${response.statusText}`); + throw new Error('Failed to move model'); } return await response.json(); } catch (error) { - console.error('Error renaming LoRA file:', error); + console.error('Error moving model:', error); + throw error; + } +} + +export async function moveModelsBulk(filePaths, targetPath) { + try { + const response = await fetch(loraApiClient.apiConfig.endpoints.specific.moveBulk, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file_paths: filePaths, + target_path: targetPath + }) + }); + + if (!response.ok) { + throw new Error('Failed to move models'); + } + + return await response.json(); + } catch (error) { + console.error('Error moving models in bulk:', error); throw error; - } finally { - // Hide loading indicator - state.loadingManager.hide(); } } \ No newline at end of file diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index 0072879d..e9e83ca7 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -1,8 +1,4 @@ import { RecipeCard } from '../components/RecipeCard.js'; -import { - resetAndReloadWithVirtualScroll, - loadMoreWithVirtualScroll -} from './baseModelApi.js'; import { state, getCurrentPageState } from '../state/index.js'; import { showToast } from '../utils/uiHelpers.js'; @@ -98,6 +94,98 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) { } } +/** + * Reset and reload models using virtual scrolling + * @param {Object} options - Operation options + * @returns {Promise} The fetch result + */ +export async function resetAndReloadWithVirtualScroll(options = {}) { + const { + modelType = 'lora', + updateFolders = false, + fetchPageFunction + } = options; + + const pageState = getCurrentPageState(); + + try { + pageState.isLoading = true; + + // Reset page counter + pageState.currentPage = 1; + + // Fetch the first page + const result = await fetchPageFunction(1, pageState.pageSize || 50); + + // Update the virtual scroller + state.virtualScroller.refreshWithData( + result.items, + result.totalItems, + result.hasMore + ); + + // Update state + pageState.hasMore = result.hasMore; + pageState.currentPage = 2; // Next page will be 2 + + return result; + } catch (error) { + console.error(`Error reloading ${modelType}s:`, error); + showToast(`Failed to reload ${modelType}s: ${error.message}`, 'error'); + throw error; + } finally { + pageState.isLoading = false; + } +} + +/** + * Load more models using virtual scrolling + * @param {Object} options - Operation options + * @returns {Promise} The fetch result + */ +export async function loadMoreWithVirtualScroll(options = {}) { + const { + modelType = 'lora', + resetPage = false, + updateFolders = false, + fetchPageFunction + } = options; + + const pageState = getCurrentPageState(); + + try { + // Start loading state + pageState.isLoading = true; + + // Reset to first page if requested + if (resetPage) { + pageState.currentPage = 1; + } + + // Fetch the first page of data + const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50); + + // Update virtual scroller with the new data + state.virtualScroller.refreshWithData( + result.items, + result.totalItems, + result.hasMore + ); + + // Update state + pageState.hasMore = result.hasMore; + pageState.currentPage = 2; // Next page to load would be 2 + + return result; + } catch (error) { + console.error(`Error loading ${modelType}s:`, error); + showToast(`Failed to load ${modelType}s: ${error.message}`, 'error'); + throw error; + } finally { + pageState.isLoading = false; + } +} + /** * Reset and reload recipes using virtual scrolling * @param {boolean} updateFolders - Whether to update folder tags diff --git a/static/js/checkpoints.js b/static/js/checkpoints.js index 68b5cb8b..69ab6224 100644 --- a/static/js/checkpoints.js +++ b/static/js/checkpoints.js @@ -5,18 +5,19 @@ import { loadMoreCheckpoints } from './api/checkpointApi.js'; import { CheckpointDownloadManager } from './managers/CheckpointDownloadManager.js'; import { CheckpointContextMenu } from './components/ContextMenu/index.js'; import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; +import { MODEL_TYPES } from './api/apiConfig.js'; // Initialize the Checkpoints page class CheckpointsPageManager { constructor() { // Initialize page controls - this.pageControls = createPageControls('checkpoints'); + this.pageControls = createPageControls(MODEL_TYPES.CHECKPOINT); // Initialize checkpoint download manager window.checkpointDownloadManager = new CheckpointDownloadManager(); // Initialize the ModelDuplicatesManager - this.duplicatesManager = new ModelDuplicatesManager(this, 'checkpoints'); + this.duplicatesManager = new ModelDuplicatesManager(this, MODEL_TYPES.CHECKPOINT); // Expose only necessary functions to global scope this._exposeRequiredGlobalFunctions(); diff --git a/static/js/components/ContextMenu/CheckpointContextMenu.js b/static/js/components/ContextMenu/CheckpointContextMenu.js index 16ab4b44..8e589b51 100644 --- a/static/js/components/ContextMenu/CheckpointContextMenu.js +++ b/static/js/components/ContextMenu/CheckpointContextMenu.js @@ -1,6 +1,7 @@ import { BaseContextMenu } from './BaseContextMenu.js'; import { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; -import { refreshSingleCheckpointMetadata, saveModelMetadata, replaceCheckpointPreview, resetAndReload } from '../../api/checkpointApi.js'; +import { resetAndReload } from '../../api/checkpointApi.js'; +import { getModelApiClient } from '../../api/baseModelApi.js'; import { showToast } from '../../utils/uiHelpers.js'; import { showExcludeModal } from '../../utils/modalUtils.js'; @@ -19,7 +20,7 @@ export class CheckpointContextMenu extends BaseContextMenu { // Implementation needed by the mixin async saveModelMetadata(filePath, data) { - return saveModelMetadata(filePath, data); + return getModelApiClient().saveModelMetadata(filePath, data); } handleMenuAction(action) { @@ -28,6 +29,8 @@ export class CheckpointContextMenu extends BaseContextMenu { return; } + const apiClient = getModelApiClient(); + // Otherwise handle checkpoint-specific actions switch(action) { case 'details': @@ -36,7 +39,7 @@ export class CheckpointContextMenu extends BaseContextMenu { break; case 'replace-preview': // Add new action for replacing preview images - replaceCheckpointPreview(this.currentCard.dataset.filepath); + apiClient.replaceModelPreview(this.currentCard.dataset.filepath); break; case 'delete': // Delete checkpoint @@ -52,14 +55,14 @@ export class CheckpointContextMenu extends BaseContextMenu { break; case 'refresh-metadata': // Refresh metadata from CivitAI - refreshSingleCheckpointMetadata(this.currentCard.dataset.filepath); + apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath); break; case 'move': // Move to folder (placeholder) showToast('Move to folder feature coming soon', 'info'); break; case 'exclude': - showExcludeModal(this.currentCard.dataset.filepath, 'checkpoint'); + showExcludeModal(this.currentCard.dataset.filepath); break; } } diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js index d0f5a125..7c4409e5 100644 --- a/static/js/components/shared/ModelCard.js +++ b/static/js/components/shared/ModelCard.js @@ -4,8 +4,7 @@ import { showModelModal } from './ModelModal.js'; import { bulkManager } from '../../managers/BulkManager.js'; import { modalManager } from '../../managers/ModalManager.js'; import { NSFW_LEVELS } from '../../utils/constants.js'; -import { replacePreview, saveModelMetadata as saveLoraMetadata } from '../../api/loraApi.js'; -import { replaceCheckpointPreview as apiReplaceCheckpointPreview, saveModelMetadata as saveCheckpointMetadata } from '../../api/checkpointApi.js'; +import { getModelApiClient } from '../../api/baseModelApi.js'; import { showDeleteModal } from '../../utils/modalUtils.js'; // Add global event delegation handlers @@ -31,6 +30,8 @@ function handleModelCardEvent_internal(event, modelType) { // Find the closest card element const card = event.target.closest('.lora-card'); if (!card) return; + + const apiClient = getModelApiClient(); // Handle specific elements within the card if (event.target.closest('.toggle-blur-btn')) { @@ -73,13 +74,13 @@ function handleModelCardEvent_internal(event, modelType) { if (event.target.closest('.fa-trash')) { event.stopPropagation(); - showDeleteModal(card.dataset.filepath, modelType); + showDeleteModal(card.dataset.filepath); return; } if (event.target.closest('.fa-image')) { event.stopPropagation(); - handleReplacePreview(card.dataset.filepath, modelType); + apiClient.replaceModelPreview(card.dataset.filepath); return; } @@ -136,9 +137,7 @@ async function toggleFavorite(card, modelType) { const newFavoriteState = !isFavorite; try { - // Use the appropriate save function based on model type - const saveFunction = modelType === 'lora' ? saveLoraMetadata : saveCheckpointMetadata; - await saveFunction(card.dataset.filepath, { + await apiClient.saveModelMetadata(card.dataset.filepath, { favorite: newFavoriteState }); @@ -179,15 +178,7 @@ function handleCopyAction(card, modelType) { } function handleReplacePreview(filePath, modelType) { - if (modelType === 'lora') { - replacePreview(filePath); - } else { - if (window.replaceCheckpointPreview) { - window.replaceCheckpointPreview(filePath); - } else { - apiReplaceCheckpointPreview(filePath); - } - } + apiClient.replaceModelPreview(filePath); } async function handleExampleImagesAccess(card, modelType) { diff --git a/static/js/components/shared/ModelMetadata.js b/static/js/components/shared/ModelMetadata.js index d6cdf1f4..27f433df 100644 --- a/static/js/components/shared/ModelMetadata.js +++ b/static/js/components/shared/ModelMetadata.js @@ -7,6 +7,7 @@ import { BASE_MODELS } from '../../utils/constants.js'; import { state } from '../../state/index.js'; import { saveModelMetadata as saveLoraMetadata, renameLoraFile } from '../../api/loraApi.js'; import { saveModelMetadata as saveCheckpointMetadata, renameCheckpointFile } from '../../api/checkpointApi.js'; +import { getModelApiClient } from '../../api/baseModelApi.js'; /** * Set up model name editing functionality @@ -114,9 +115,7 @@ export function setupModelNameEditing(filePath) { // Get the file path from the dataset const filePath = this.dataset.filePath; - const saveFunction = state.currentPageType === 'checkpoints' ? saveCheckpointMetadata : saveLoraMetadata; - - await saveFunction(filePath, { model_name: newModelName }); + await getModelApiClient().saveModelMetadata(filePath, { model_name: newModelName }); showToast('Model name updated successfully', 'success'); } catch (error) { @@ -295,9 +294,7 @@ async function saveBaseModel(filePath, originalValue) { } try { - const saveFunction = state.currentPageType === 'checkpoints' ? saveCheckpointMetadata : saveLoraMetadata; - - await saveFunction(filePath, { base_model: newBaseModel }); + await getModelApiClient().saveModelMetadata(filePath, { base_model: newBaseModel }); showToast('Base model updated successfully', 'success'); } catch (error) { @@ -417,29 +414,7 @@ export function setupFileNameEditing(filePath) { // Get the file path from the dataset const filePath = this.dataset.filePath; - let result; - - if (state.currentPageType === 'checkpoints') { - result = await renameCheckpointFile(filePath, newFileName); - } else { - // Use LoRA rename function - result = await renameLoraFile(filePath, newFileName); - } - - if (result.success) { - showToast('File name updated successfully', 'success'); - - // Update virtual scroller if available (mainly for LoRAs) - if (state.virtualScroller && typeof state.virtualScroller.updateSingleItem === 'function') { - const newFilePath = filePath.replace(originalValue, newFileName); - state.virtualScroller.updateSingleItem(filePath, { - file_name: newFileName, - file_path: newFilePath - }); - } - } else { - throw new Error(result.error || 'Unknown error'); - } + await getModelApiClient().renameModelFile(filePath, newFileName); } catch (error) { console.error('Error renaming file:', error); this.textContent = originalValue; // Restore original file name diff --git a/static/js/components/shared/showcase/MediaUtils.js b/static/js/components/shared/showcase/MediaUtils.js index e7580262..5a19749f 100644 --- a/static/js/components/shared/showcase/MediaUtils.js +++ b/static/js/components/shared/showcase/MediaUtils.js @@ -5,7 +5,7 @@ */ import { showToast, copyToClipboard } from '../../../utils/uiHelpers.js'; import { state } from '../../../state/index.js'; -import { uploadPreview } from '../../../api/baseModelApi.js'; +import { getModelApiClient } from '../../../api/baseModelApi.js'; /** * Try to load local image first, fall back to remote if local fails @@ -515,6 +515,7 @@ function initSetPreviewHandlers(container) { // Get local file path if available const useLocalFile = mediaElement.dataset.localSrc && !mediaElement.dataset.localSrc.includes('undefined'); + const apiClient = getModelApiClient(); if (useLocalFile) { // We have a local file, use it directly @@ -523,7 +524,7 @@ function initSetPreviewHandlers(container) { const file = new File([blob], 'preview.jpg', { type: blob.type }); // Use the existing baseModelApi uploadPreview method with nsfw level - await uploadPreview(modelFilePath, file, modelType, nsfwLevel); + await apiClient.uploadPreview(modelFilePath, file, modelType, nsfwLevel); } else { // We need to download the remote file first const response = await fetch(mediaElement.src); @@ -531,7 +532,7 @@ function initSetPreviewHandlers(container) { const file = new File([blob], 'preview.jpg', { type: blob.type }); // Use the existing baseModelApi uploadPreview method with nsfw level - await uploadPreview(modelFilePath, file, modelType, nsfwLevel); + await apiClient.uploadPreview(modelFilePath, file, modelType, nsfwLevel); } } catch (error) { console.error('Error setting preview:', error); diff --git a/static/js/managers/MoveManager.js b/static/js/managers/MoveManager.js index 50f735ae..3798edab 100644 --- a/static/js/managers/MoveManager.js +++ b/static/js/managers/MoveManager.js @@ -1,8 +1,7 @@ -import { showToast } from '../utils/uiHelpers.js'; +import { showToast, updateFolderTags } from '../utils/uiHelpers.js'; import { state, getCurrentPageState } from '../state/index.js'; import { modalManager } from './ModalManager.js'; import { getStorageItem } from '../utils/storageHelpers.js'; -import { updateFolderTags } from '../api/baseModelApi.js'; class MoveManager { constructor() { @@ -219,7 +218,7 @@ class MoveManager { return; } - const response = await fetch('/api/loras/move_model', { + const response = await fetch('/api/move_model', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -257,7 +256,7 @@ class MoveManager { return; } - const response = await fetch('/api/loras/move_models_bulk', { + const response = await fetch('/api/move_models_bulk', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/static/js/state/index.js b/static/js/state/index.js index 8343692d..ac77bbc2 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -1,16 +1,18 @@ // Create the new hierarchical state structure import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js'; +import { MODEL_TYPES } from '../api/apiConfig.js'; // Load settings from localStorage or use defaults const savedSettings = getStorageItem('settings', { blurMatureContent: true, show_only_sfw: false, - cardInfoDisplay: 'always' // Add default value for card info display + cardInfoDisplay: 'always' }); -// Load preview versions from localStorage +// Load preview versions from localStorage for each model type const loraPreviewVersions = getMapFromStorage('lora_preview_versions'); const checkpointPreviewVersions = getMapFromStorage('checkpoint_preview_versions'); +const embeddingPreviewVersions = getMapFromStorage('embedding_preview_versions'); export const state = { // Global state @@ -22,13 +24,13 @@ export const state = { // Page-specific states pages: { - loras: { + [MODEL_TYPES.LORA]: { currentPage: 1, isLoading: false, hasMore: true, sortBy: 'name', activeFolder: null, - activeLetterFilter: null, // New property for letter filtering + activeLetterFilter: null, previewVersions: loraPreviewVersions, searchManager: null, searchOptions: { @@ -67,10 +69,10 @@ export const state = { }, pageSize: 20, showFavoritesOnly: false, - duplicatesMode: false, // Add flag for duplicates mode + duplicatesMode: false, }, - checkpoints: { + [MODEL_TYPES.CHECKPOINT]: { currentPage: 1, isLoading: false, hasMore: true, @@ -89,11 +91,34 @@ export const state = { }, showFavoritesOnly: false, duplicatesMode: false, + }, + + [MODEL_TYPES.EMBEDDING]: { + currentPage: 1, + isLoading: false, + hasMore: true, + sortBy: 'name', + activeFolder: null, + activeLetterFilter: null, + previewVersions: embeddingPreviewVersions, + searchManager: null, + searchOptions: { + filename: true, + modelname: true, + tags: false, + recursive: false + }, + filters: { + baseModel: [], + tags: [] + }, + showFavoritesOnly: false, + duplicatesMode: false, } }, - // Current active page - currentPageType: 'loras', + // Current active page - use MODEL_TYPES constants + currentPageType: MODEL_TYPES.LORA, // Backward compatibility - proxy properties get currentPage() { return this.pages[this.currentPageType].currentPage; }, diff --git a/static/js/utils/modalUtils.js b/static/js/utils/modalUtils.js index 041a8ee3..512b0c31 100644 --- a/static/js/utils/modalUtils.js +++ b/static/js/utils/modalUtils.js @@ -1,15 +1,13 @@ import { modalManager } from '../managers/ModalManager.js'; -import { excludeLora, deleteModel as deleteLora } from '../api/loraApi.js'; -import { excludeCheckpoint, deleteCheckpoint } from '../api/checkpointApi.js'; +import { getModelApiClient } from '../api/baseModelApi.js'; + +const apiClient = getModelApiClient(); let pendingDeletePath = null; -let pendingModelType = null; let pendingExcludePath = null; -let pendingExcludeModelType = null; -export function showDeleteModal(filePath, modelType = 'lora') { +export function showDeleteModal(filePath) { pendingDeletePath = filePath; - pendingModelType = modelType; const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); const modelName = card ? card.dataset.name : filePath.split('/').pop(); @@ -29,12 +27,7 @@ export async function confirmDelete() { if (!pendingDeletePath) return; try { - // Use appropriate delete function based on model type - if (pendingModelType === 'checkpoint') { - await deleteCheckpoint(pendingDeletePath); - } else { - await deleteLora(pendingDeletePath); - } + await apiClient.deleteModel(pendingDeletePath); closeDeleteModal(); @@ -54,9 +47,8 @@ export function closeDeleteModal() { } // Functions for the exclude modal -export function showExcludeModal(filePath, modelType = 'lora') { +export function showExcludeModal(filePath) { pendingExcludePath = filePath; - pendingExcludeModelType = modelType; const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); const modelName = card ? card.dataset.name : filePath.split('/').pop(); @@ -82,12 +74,7 @@ export async function confirmExclude() { if (!pendingExcludePath) return; try { - // Use appropriate exclude function based on model type - if (pendingExcludeModelType === 'checkpoint') { - await excludeCheckpoint(pendingExcludePath); - } else { - await excludeLora(pendingExcludePath); - } + await apiClient.excludeModel(pendingExcludePath); closeExcludeModal(); diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index adaf3b66..1d03a01f 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -615,4 +615,31 @@ export async function openExampleImagesFolder(modelHash) { showToast('Failed to open example images folder', 'error'); return false; } +} + +/** + * Update the folder tags display with new folder list + * @param {Array} folders - List of folder names + */ +export function 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 `
${folder}
`; + }).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' }); + } } \ No newline at end of file From 26d51b11909438930e00b3ef11ed03ff475e405d Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Fri, 25 Jul 2025 14:44:05 +0800 Subject: [PATCH 03/20] refactor: Simplify API calls and enhance model moving functionality --- py/utils/routes_common.py | 2 - static/js/api/baseModelApi.js | 203 +++++++++++++++++++------- static/js/api/checkpointApi.js | 4 +- static/js/api/loraApi.js | 4 +- static/js/managers/DownloadManager.js | 2 - static/js/managers/MoveManager.js | 146 ++++-------------- static/js/utils/VirtualScroller.js | 17 --- static/js/utils/modalUtils.js | 2 - static/js/utils/uiHelpers.js | 5 +- 9 files changed, 186 insertions(+), 199 deletions(-) diff --git a/py/utils/routes_common.py b/py/utils/routes_common.py index 4e5c9807..8f00106b 100644 --- a/py/utils/routes_common.py +++ b/py/utils/routes_common.py @@ -329,8 +329,6 @@ class ModelRouteUtils: # Update hash index if available if hasattr(scanner, '_hash_index') and scanner._hash_index: scanner._hash_index.remove_by_path(file_path) - - await scanner._save_cache_to_disk() return web.json_response({ 'success': True, diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 116521ed..a3d93da4 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1,5 +1,5 @@ import { state, getCurrentPageState } from '../state/index.js'; -import { showToast } from '../utils/uiHelpers.js'; +import { showToast, updateFolderTags } from '../utils/uiHelpers.js'; import { getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js'; import { getCompleteApiConfig, @@ -79,6 +79,53 @@ class ModelApiClient { } } + /** + * Reset and reload models with virtual scrolling + */ + async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) { + const pageState = this.getPageState(); + + try { + state.loadingManager.showSimpleLoading(`Loading more ${this.apiConfig.config.displayName}s...`); + + pageState.isLoading = true; + if (resetPage) { + pageState.currentPage = 1; // Reset to first page + } + + // Fetch the current page + const startTime = performance.now(); + const result = await this.fetchModelsPage(pageState.currentPage, pageState.pageSize); + const endTime = performance.now(); + console.log(`fetchModelsPage耗时: ${(endTime - startTime).toFixed(2)} ms`); + + // Update the virtual scroller + state.virtualScroller.refreshWithData( + result.items, + result.totalItems, + result.hasMore + ); + + // Update state + pageState.hasMore = result.hasMore; + pageState.currentPage = pageState.currentPage + 1; + + // Update folders if needed + if (updateFolders && result.folders) { + updateFolderTags(result.folders); + } + + 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'); + throw error; + } finally { + pageState.isLoading = false; + state.loadingManager.hide(); + } + } + /** * Delete a model */ @@ -355,7 +402,7 @@ class ModelApiClient { /** * Fetch CivitAI metadata for all models */ - async fetchCivitaiMetadata(resetAndReloadFunction) { + async fetchCivitaiMetadata() { let ws = null; await state.loadingManager.showWithProgress(async (loading) => { @@ -416,10 +463,6 @@ class ModelApiClient { await operationComplete; - if (typeof resetAndReloadFunction === 'function') { - await resetAndReloadFunction(); - } - } catch (error) { console.error('Error fetching metadata:', error); showToast('Failed to fetch metadata: ' + error.message, 'error'); @@ -434,6 +477,110 @@ class ModelApiClient { }); } + /** + * Move a single model to target path + * @returns {string|null} - The new file path if moved, null if not moved + */ + async moveSingleModel(filePath, targetPath) { + if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) { + showToast('Model is already in the selected folder', 'info'); + return null; + } + + const response = await fetch(this.apiConfig.endpoints.specific.moveModel, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_path: filePath, + target_path: targetPath + }) + }); + + const result = await response.json(); + + if (!response.ok) { + if (result && result.error) { + throw new Error(result.error); + } + throw new Error('Failed to move model'); + } + + if (result && result.message) { + showToast(result.message, 'info'); + } else { + showToast('Model moved successfully', 'success'); + } + + // Return new file path if move succeeded + if (result.success) { + return targetPath; + } + return null; + } + + /** + * Move multiple models to target path + * @returns {Array} - Array of new file paths that were moved successfully + */ + async moveBulkModels(filePaths, targetPath) { + const movedPaths = filePaths.filter(path => { + return path.substring(0, path.lastIndexOf('/')) !== targetPath; + }); + + if (movedPaths.length === 0) { + showToast('All selected models are already in the target folder', 'info'); + return []; + } + + const response = await fetch(this.apiConfig.endpoints.specific.moveBulk, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_paths: movedPaths, + target_path: targetPath + }) + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error('Failed to move models'); + } + + let successFilePaths = []; + if (result.success) { + if (result.failure_count > 0) { + showToast(`Moved ${result.success_count} models, ${result.failure_count} failed`, '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); + 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); + } + } else { + showToast(`Successfully moved ${result.success_count} models`, 'success'); + } + // Collect new file paths for successful moves + successFilePaths = result.results + .filter(r => r.success && r.new_file_path) + .map(r => r.new_file_path); + } else { + throw new Error(result.message || 'Failed to move models'); + } + return successFilePaths; + } + /** * Build query parameters for API requests */ @@ -527,48 +674,4 @@ export function getModelApiClient() { } _singletonClient.setModelType(state.currentPageType); return _singletonClient; -} - -// Legacy compatibility exports -export async function fetchModelsPage(options = {}) { - const { modelType = getCurrentModelType(), ...rest } = options; - const client = createModelApiClient(modelType); - return client.fetchModelsPage(rest.page, rest.pageSize); -} - -export async function deleteModel(filePath, modelType = null) { - const client = createModelApiClient(modelType); - return client.deleteModel(filePath); -} - -export async function excludeModel(filePath, modelType = null) { - const client = createModelApiClient(modelType); - return client.excludeModel(filePath); -} - -export async function renameModelFile(filePath, newFileName, modelType = null) { - const client = createModelApiClient(modelType); - return client.renameModelFile(filePath, newFileName); -} - -export async function replaceModelPreview(filePath, modelType = null) { - const client = createModelApiClient(modelType); - return client.replaceModelPreview(filePath); -} - -export async function refreshModels(options = {}) { - const { modelType = getCurrentModelType(), fullRebuild = false } = options; - const client = createModelApiClient(modelType); - return client.refreshModels(fullRebuild); -} - -export async function refreshSingleModelMetadata(filePath, modelType = null) { - const client = createModelApiClient(modelType); - return client.refreshSingleModelMetadata(filePath); -} - -export async function fetchCivitaiMetadata(options = {}) { - const { modelType = getCurrentModelType(), resetAndReloadFunction } = options; - const client = createModelApiClient(modelType); - return client.fetchCivitaiMetadata(resetAndReloadFunction); } \ No newline at end of file diff --git a/static/js/api/checkpointApi.js b/static/js/api/checkpointApi.js index 574572a1..048de2ae 100644 --- a/static/js/api/checkpointApi.js +++ b/static/js/api/checkpointApi.js @@ -12,7 +12,7 @@ export const replacePreview = (filePath) => checkpointApiClient.replaceModelPrev export const saveModelMetadata = (filePath, data) => checkpointApiClient.saveModelMetadata(filePath, data); export const refreshCheckpoints = (fullRebuild = false) => checkpointApiClient.refreshModels(fullRebuild); export const refreshSingleCheckpointMetadata = (filePath) => checkpointApiClient.refreshSingleModelMetadata(filePath); -export const fetchCivitai = (resetAndReloadFunction) => checkpointApiClient.fetchCivitaiMetadata(resetAndReloadFunction); +export const fetchCivitai = () => checkpointApiClient.fetchCivitaiMetadata(); // Pagination functions export const fetchCheckpointsPage = (page = 1, pageSize = 50) => checkpointApiClient.fetchModelsPage(page, pageSize); @@ -23,7 +23,7 @@ export async function loadMoreCheckpoints(resetPage = false, updateFolders = fal } export async function resetAndReload(updateFolders = false) { - return checkpointApiClient.resetAndReloadWithVirtualScroll(updateFolders); + return checkpointApiClient.loadMoreWithVirtualScroll(true, updateFolders); } // Checkpoint-specific functions diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js index 738571fb..49a06bbc 100644 --- a/static/js/api/loraApi.js +++ b/static/js/api/loraApi.js @@ -12,7 +12,7 @@ export const replacePreview = (filePath) => loraApiClient.replaceModelPreview(fi export const saveModelMetadata = (filePath, data) => loraApiClient.saveModelMetadata(filePath, data); export const refreshLoras = (fullRebuild = false) => loraApiClient.refreshModels(fullRebuild); export const refreshSingleLoraMetadata = (filePath) => loraApiClient.refreshSingleModelMetadata(filePath); -export const fetchCivitai = (resetAndReloadFunction) => loraApiClient.fetchCivitaiMetadata(resetAndReloadFunction); +export const fetchCivitai = () => loraApiClient.fetchCivitaiMetadata(); // Pagination functions export const fetchLorasPage = (page = 1, pageSize = 100) => loraApiClient.fetchModelsPage(page, pageSize); @@ -23,7 +23,7 @@ export async function loadMoreLoras(resetPage = false, updateFolders = false) { } export async function resetAndReload(updateFolders = false) { - return loraApiClient.resetAndReloadWithVirtualScroll(updateFolders); + return loraApiClient.loadMoreWithVirtualScroll(true, updateFolders); } // LoRA-specific functions that don't have common equivalents diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index b3d0433e..f4f5b9ba 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -155,8 +155,6 @@ export class DownloadManager { `; } - - console.log(earlyAccessBadge); // Status badge for local models const localStatus = existsLocally ? diff --git a/static/js/managers/MoveManager.js b/static/js/managers/MoveManager.js index 3798edab..5f14ea1e 100644 --- a/static/js/managers/MoveManager.js +++ b/static/js/managers/MoveManager.js @@ -2,6 +2,7 @@ import { showToast, updateFolderTags } from '../utils/uiHelpers.js'; import { state, getCurrentPageState } from '../state/index.js'; import { modalManager } from './ModalManager.js'; import { getStorageItem } from '../utils/storageHelpers.js'; +import { getModelApiClient } from '../api/baseModelApi.js'; class MoveManager { constructor() { @@ -145,45 +146,46 @@ class MoveManager { targetPath = `${targetPath}/${newFolder}`; } + const apiClient = getModelApiClient(); + try { if (this.bulkFilePaths) { // Bulk move mode - await this.moveBulkModels(this.bulkFilePaths, targetPath); + const movedFilePaths = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath); // Update virtual scroller if in active folder view const pageState = getCurrentPageState(); if (pageState.activeFolder !== null && state.virtualScroller) { - // Remove moved items from virtual scroller instead of reloading - this.bulkFilePaths.forEach(filePath => { - state.virtualScroller.removeItemByFilePath(filePath); + // 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); + } }); } else { // Update the model cards' filepath in the DOM - this.bulkFilePaths.forEach(filePath => { - // Extract filename from original path - const filename = filePath.substring(filePath.lastIndexOf('/') + 1); - // Construct new filepath - const newFilePath = `${targetPath}/${filename}`; - - state.virtualScroller.updateSingleItem(filePath, {file_path: newFilePath}); + 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}); + } }); } } else { // Single move mode - await this.moveSingleModel(this.currentFilePath, targetPath); - - // Update virtual scroller if in active folder view - const pageState = getCurrentPageState(); - if (pageState.activeFolder !== null && state.virtualScroller) { - // Remove moved item from virtual scroller instead of reloading - state.virtualScroller.removeItemByFilePath(this.currentFilePath); - } else { - // Extract filename from original path - const filename = this.currentFilePath.substring(this.currentFilePath.lastIndexOf('/') + 1); - // Construct new filepath - const newFilePath = `${targetPath}/${filename}`; + const newFilePath = await apiClient.moveSingleModel(this.currentFilePath, targetPath); - state.virtualScroller.updateSingleItem(this.currentFilePath, {file_path: newFilePath}); + const pageState = getCurrentPageState(); + if (newFilePath) { + if (pageState.activeFolder !== null && state.virtualScroller) { + state.virtualScroller.removeItemByFilePath(this.currentFilePath); + } else { + state.virtualScroller.updateSingleItem(this.currentFilePath, {file_path: newFilePath}); + } } } @@ -210,102 +212,6 @@ class MoveManager { showToast('Failed to move model(s): ' + error.message, 'error'); } } - - async moveSingleModel(filePath, targetPath) { - // show toast if current path is same as target path - if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) { - showToast('Model is already in the selected folder', 'info'); - return; - } - - const response = await fetch('/api/move_model', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath, - target_path: targetPath - }) - }); - - const result = await response.json(); - - if (!response.ok) { - if (result && result.error) { - throw new Error(result.error); - } - throw new Error('Failed to move model'); - } - - if (result && result.message) { - showToast(result.message, 'info'); - } else { - showToast('Model moved successfully', 'success'); - } - } - - async moveBulkModels(filePaths, targetPath) { - // Filter out models already in the target path - const movedPaths = filePaths.filter(path => { - return path.substring(0, path.lastIndexOf('/')) !== targetPath; - }); - - if (movedPaths.length === 0) { - showToast('All selected models are already in the target folder', 'info'); - return; - } - - const response = await fetch('/api/move_models_bulk', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_paths: movedPaths, - target_path: targetPath - }) - }); - - const result = await response.json(); - - if (!response.ok) { - throw new Error('Failed to move models'); - } - - // Display results with more details - if (result.success) { - if (result.failure_count > 0) { - // Some files failed to move - showToast(`Moved ${result.success_count} models, ${result.failure_count} failed`, 'warning'); - - // Log details about failures - console.log('Move operation results:', result.results); - - // Get list of failed files with reasons - const failedFiles = result.results - .filter(r => !r.success) - .map(r => { - const fileName = r.path.substring(r.path.lastIndexOf('/') + 1); - return `${fileName}: ${r.message}`; - }); - - // Show first few failures in a toast - 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); - } - } else { - // All files moved successfully - showToast(`Successfully moved ${result.success_count} models`, 'success'); - } - } else { - throw new Error(result.message || 'Failed to move models'); - } - } } export const moveManager = new MoveManager(); diff --git a/static/js/utils/VirtualScroller.js b/static/js/utils/VirtualScroller.js index bbdc73b6..0d614211 100644 --- a/static/js/utils/VirtualScroller.js +++ b/static/js/utils/VirtualScroller.js @@ -164,23 +164,6 @@ export class VirtualScroller { // Calculate the left offset to center the grid within the content area this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2); - - // Log layout info - console.log('Virtual Scroll Layout:', { - containerWidth, - availableContentWidth, - actualGridWidth, - columnsCount: this.columnsCount, - itemWidth: this.itemWidth, - itemHeight: this.itemHeight, - leftOffset: this.leftOffset, - paddingLeft, - paddingRight, - displayDensity, - maxColumns, - baseCardWidth, - rowGap: this.rowGap - }); // Update grid element max-width to match available width this.gridElement.style.maxWidth = `${actualGridWidth}px`; diff --git a/static/js/utils/modalUtils.js b/static/js/utils/modalUtils.js index 512b0c31..19a0edf5 100644 --- a/static/js/utils/modalUtils.js +++ b/static/js/utils/modalUtils.js @@ -43,7 +43,6 @@ export async function confirmDelete() { export function closeDeleteModal() { modalManager.closeModal('deleteModal'); pendingDeletePath = null; - pendingModelType = null; } // Functions for the exclude modal @@ -67,7 +66,6 @@ export function showExcludeModal(filePath) { export function closeExcludeModal() { modalManager.closeModal('excludeModal'); pendingExcludePath = null; - pendingExcludeModelType = null; } export async function confirmExclude() { diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index 1d03a01f..0b46245b 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -1,4 +1,4 @@ -import { state } from '../state/index.js'; +import { state, getCurrentPageState } from '../state/index.js'; import { resetAndReload } from '../api/loraApi.js'; import { getStorageItem, setStorageItem } from './storageHelpers.js'; import { NODE_TYPES, NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js'; @@ -626,7 +626,8 @@ export function updateFolderTags(folders) { if (!folderTagsContainer) return; // Keep track of currently selected folder - const currentFolder = this.pageState.activeFolder; + const pageState = getCurrentPageState(); + const currentFolder = pageState.activeFolder; // Create HTML for folder tags const tagsHTML = folders.map(folder => { From c784615f11a8d31cb1a582161fe94ff0ac773801 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Fri, 25 Jul 2025 14:44:05 +0800 Subject: [PATCH 04/20] refactor: Simplify API calls and enhance model moving functionality --- py/utils/routes_common.py | 2 - static/js/api/baseModelApi.js | 203 +++++++++++++++++++------- static/js/api/checkpointApi.js | 4 +- static/js/api/loraApi.js | 4 +- static/js/managers/DownloadManager.js | 2 - static/js/managers/MoveManager.js | 146 ++++-------------- static/js/utils/VirtualScroller.js | 17 --- static/js/utils/modalUtils.js | 2 - static/js/utils/uiHelpers.js | 5 +- 9 files changed, 186 insertions(+), 199 deletions(-) diff --git a/py/utils/routes_common.py b/py/utils/routes_common.py index 4e5c9807..8f00106b 100644 --- a/py/utils/routes_common.py +++ b/py/utils/routes_common.py @@ -329,8 +329,6 @@ class ModelRouteUtils: # Update hash index if available if hasattr(scanner, '_hash_index') and scanner._hash_index: scanner._hash_index.remove_by_path(file_path) - - await scanner._save_cache_to_disk() return web.json_response({ 'success': True, diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 116521ed..50a1af16 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1,5 +1,5 @@ import { state, getCurrentPageState } from '../state/index.js'; -import { showToast } from '../utils/uiHelpers.js'; +import { showToast, updateFolderTags } from '../utils/uiHelpers.js'; import { getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js'; import { getCompleteApiConfig, @@ -79,6 +79,53 @@ class ModelApiClient { } } + /** + * Reset and reload models with virtual scrolling + */ + async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) { + const pageState = this.getPageState(); + + try { + state.loadingManager.showSimpleLoading(`Loading more ${this.apiConfig.config.displayName}s...`); + + pageState.isLoading = true; + if (resetPage) { + pageState.currentPage = 1; // Reset to first page + } + + // Fetch the current page + const startTime = performance.now(); + const result = await this.fetchModelsPage(pageState.currentPage, pageState.pageSize); + const endTime = performance.now(); + console.log(`fetchModelsPage耗时: ${(endTime - startTime).toFixed(2)} ms`); + + // Update the virtual scroller + state.virtualScroller.refreshWithData( + result.items, + result.totalItems, + result.hasMore + ); + + // Update state + pageState.hasMore = result.hasMore; + pageState.currentPage = pageState.currentPage + 1; + + // Update folders if needed + if (updateFolders && result.folders) { + updateFolderTags(result.folders); + } + + 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'); + throw error; + } finally { + pageState.isLoading = false; + state.loadingManager.hide(); + } + } + /** * Delete a model */ @@ -355,7 +402,7 @@ class ModelApiClient { /** * Fetch CivitAI metadata for all models */ - async fetchCivitaiMetadata(resetAndReloadFunction) { + async fetchCivitaiMetadata() { let ws = null; await state.loadingManager.showWithProgress(async (loading) => { @@ -416,10 +463,6 @@ class ModelApiClient { await operationComplete; - if (typeof resetAndReloadFunction === 'function') { - await resetAndReloadFunction(); - } - } catch (error) { console.error('Error fetching metadata:', error); showToast('Failed to fetch metadata: ' + error.message, 'error'); @@ -434,6 +477,110 @@ class ModelApiClient { }); } + /** + * Move a single model to target path + * @returns {string|null} - The new file path if moved, null if not moved + */ + async moveSingleModel(filePath, targetPath) { + if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) { + showToast('Model is already in the selected folder', 'info'); + return null; + } + + const response = await fetch(this.apiConfig.endpoints.specific.moveModel, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_path: filePath, + target_path: targetPath + }) + }); + + const result = await response.json(); + + if (!response.ok) { + if (result && result.error) { + throw new Error(result.error); + } + throw new Error('Failed to move model'); + } + + if (result && result.message) { + showToast(result.message, 'info'); + } else { + showToast('Model moved successfully', 'success'); + } + + // Return new file path if move succeeded + if (result.success) { + return targetPath; + } + return null; + } + + /** + * Move multiple models to target path + * @returns {Array} - Array of new file paths that were moved successfully + */ + async moveBulkModels(filePaths, targetPath) { + const movedPaths = filePaths.filter(path => { + return path.substring(0, path.lastIndexOf('/')) !== targetPath; + }); + + if (movedPaths.length === 0) { + showToast('All selected models are already in the target folder', 'info'); + return []; + } + + const response = await fetch(this.apiConfig.endpoints.specific.moveBulk, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_paths: movedPaths, + target_path: targetPath + }) + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error('Failed to move models'); + } + + let successFilePaths = []; + if (result.success) { + if (result.failure_count > 0) { + showToast(`Moved ${result.success_count} models, ${result.failure_count} failed`, '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); + 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); + } + } else { + showToast(`Successfully moved ${result.success_count} models`, 'success'); + } + // Collect new file paths for successful moves + successFilePaths = result.results + .filter(r => r.success) + .map(r => r.path); + } else { + throw new Error(result.message || 'Failed to move models'); + } + return successFilePaths; + } + /** * Build query parameters for API requests */ @@ -527,48 +674,4 @@ export function getModelApiClient() { } _singletonClient.setModelType(state.currentPageType); return _singletonClient; -} - -// Legacy compatibility exports -export async function fetchModelsPage(options = {}) { - const { modelType = getCurrentModelType(), ...rest } = options; - const client = createModelApiClient(modelType); - return client.fetchModelsPage(rest.page, rest.pageSize); -} - -export async function deleteModel(filePath, modelType = null) { - const client = createModelApiClient(modelType); - return client.deleteModel(filePath); -} - -export async function excludeModel(filePath, modelType = null) { - const client = createModelApiClient(modelType); - return client.excludeModel(filePath); -} - -export async function renameModelFile(filePath, newFileName, modelType = null) { - const client = createModelApiClient(modelType); - return client.renameModelFile(filePath, newFileName); -} - -export async function replaceModelPreview(filePath, modelType = null) { - const client = createModelApiClient(modelType); - return client.replaceModelPreview(filePath); -} - -export async function refreshModels(options = {}) { - const { modelType = getCurrentModelType(), fullRebuild = false } = options; - const client = createModelApiClient(modelType); - return client.refreshModels(fullRebuild); -} - -export async function refreshSingleModelMetadata(filePath, modelType = null) { - const client = createModelApiClient(modelType); - return client.refreshSingleModelMetadata(filePath); -} - -export async function fetchCivitaiMetadata(options = {}) { - const { modelType = getCurrentModelType(), resetAndReloadFunction } = options; - const client = createModelApiClient(modelType); - return client.fetchCivitaiMetadata(resetAndReloadFunction); } \ No newline at end of file diff --git a/static/js/api/checkpointApi.js b/static/js/api/checkpointApi.js index 574572a1..048de2ae 100644 --- a/static/js/api/checkpointApi.js +++ b/static/js/api/checkpointApi.js @@ -12,7 +12,7 @@ export const replacePreview = (filePath) => checkpointApiClient.replaceModelPrev export const saveModelMetadata = (filePath, data) => checkpointApiClient.saveModelMetadata(filePath, data); export const refreshCheckpoints = (fullRebuild = false) => checkpointApiClient.refreshModels(fullRebuild); export const refreshSingleCheckpointMetadata = (filePath) => checkpointApiClient.refreshSingleModelMetadata(filePath); -export const fetchCivitai = (resetAndReloadFunction) => checkpointApiClient.fetchCivitaiMetadata(resetAndReloadFunction); +export const fetchCivitai = () => checkpointApiClient.fetchCivitaiMetadata(); // Pagination functions export const fetchCheckpointsPage = (page = 1, pageSize = 50) => checkpointApiClient.fetchModelsPage(page, pageSize); @@ -23,7 +23,7 @@ export async function loadMoreCheckpoints(resetPage = false, updateFolders = fal } export async function resetAndReload(updateFolders = false) { - return checkpointApiClient.resetAndReloadWithVirtualScroll(updateFolders); + return checkpointApiClient.loadMoreWithVirtualScroll(true, updateFolders); } // Checkpoint-specific functions diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js index 738571fb..49a06bbc 100644 --- a/static/js/api/loraApi.js +++ b/static/js/api/loraApi.js @@ -12,7 +12,7 @@ export const replacePreview = (filePath) => loraApiClient.replaceModelPreview(fi export const saveModelMetadata = (filePath, data) => loraApiClient.saveModelMetadata(filePath, data); export const refreshLoras = (fullRebuild = false) => loraApiClient.refreshModels(fullRebuild); export const refreshSingleLoraMetadata = (filePath) => loraApiClient.refreshSingleModelMetadata(filePath); -export const fetchCivitai = (resetAndReloadFunction) => loraApiClient.fetchCivitaiMetadata(resetAndReloadFunction); +export const fetchCivitai = () => loraApiClient.fetchCivitaiMetadata(); // Pagination functions export const fetchLorasPage = (page = 1, pageSize = 100) => loraApiClient.fetchModelsPage(page, pageSize); @@ -23,7 +23,7 @@ export async function loadMoreLoras(resetPage = false, updateFolders = false) { } export async function resetAndReload(updateFolders = false) { - return loraApiClient.resetAndReloadWithVirtualScroll(updateFolders); + return loraApiClient.loadMoreWithVirtualScroll(true, updateFolders); } // LoRA-specific functions that don't have common equivalents diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index b3d0433e..f4f5b9ba 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -155,8 +155,6 @@ export class DownloadManager { `; } - - console.log(earlyAccessBadge); // Status badge for local models const localStatus = existsLocally ? diff --git a/static/js/managers/MoveManager.js b/static/js/managers/MoveManager.js index 3798edab..5f14ea1e 100644 --- a/static/js/managers/MoveManager.js +++ b/static/js/managers/MoveManager.js @@ -2,6 +2,7 @@ import { showToast, updateFolderTags } from '../utils/uiHelpers.js'; import { state, getCurrentPageState } from '../state/index.js'; import { modalManager } from './ModalManager.js'; import { getStorageItem } from '../utils/storageHelpers.js'; +import { getModelApiClient } from '../api/baseModelApi.js'; class MoveManager { constructor() { @@ -145,45 +146,46 @@ class MoveManager { targetPath = `${targetPath}/${newFolder}`; } + const apiClient = getModelApiClient(); + try { if (this.bulkFilePaths) { // Bulk move mode - await this.moveBulkModels(this.bulkFilePaths, targetPath); + const movedFilePaths = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath); // Update virtual scroller if in active folder view const pageState = getCurrentPageState(); if (pageState.activeFolder !== null && state.virtualScroller) { - // Remove moved items from virtual scroller instead of reloading - this.bulkFilePaths.forEach(filePath => { - state.virtualScroller.removeItemByFilePath(filePath); + // 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); + } }); } else { // Update the model cards' filepath in the DOM - this.bulkFilePaths.forEach(filePath => { - // Extract filename from original path - const filename = filePath.substring(filePath.lastIndexOf('/') + 1); - // Construct new filepath - const newFilePath = `${targetPath}/${filename}`; - - state.virtualScroller.updateSingleItem(filePath, {file_path: newFilePath}); + 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}); + } }); } } else { // Single move mode - await this.moveSingleModel(this.currentFilePath, targetPath); - - // Update virtual scroller if in active folder view - const pageState = getCurrentPageState(); - if (pageState.activeFolder !== null && state.virtualScroller) { - // Remove moved item from virtual scroller instead of reloading - state.virtualScroller.removeItemByFilePath(this.currentFilePath); - } else { - // Extract filename from original path - const filename = this.currentFilePath.substring(this.currentFilePath.lastIndexOf('/') + 1); - // Construct new filepath - const newFilePath = `${targetPath}/${filename}`; + const newFilePath = await apiClient.moveSingleModel(this.currentFilePath, targetPath); - state.virtualScroller.updateSingleItem(this.currentFilePath, {file_path: newFilePath}); + const pageState = getCurrentPageState(); + if (newFilePath) { + if (pageState.activeFolder !== null && state.virtualScroller) { + state.virtualScroller.removeItemByFilePath(this.currentFilePath); + } else { + state.virtualScroller.updateSingleItem(this.currentFilePath, {file_path: newFilePath}); + } } } @@ -210,102 +212,6 @@ class MoveManager { showToast('Failed to move model(s): ' + error.message, 'error'); } } - - async moveSingleModel(filePath, targetPath) { - // show toast if current path is same as target path - if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) { - showToast('Model is already in the selected folder', 'info'); - return; - } - - const response = await fetch('/api/move_model', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath, - target_path: targetPath - }) - }); - - const result = await response.json(); - - if (!response.ok) { - if (result && result.error) { - throw new Error(result.error); - } - throw new Error('Failed to move model'); - } - - if (result && result.message) { - showToast(result.message, 'info'); - } else { - showToast('Model moved successfully', 'success'); - } - } - - async moveBulkModels(filePaths, targetPath) { - // Filter out models already in the target path - const movedPaths = filePaths.filter(path => { - return path.substring(0, path.lastIndexOf('/')) !== targetPath; - }); - - if (movedPaths.length === 0) { - showToast('All selected models are already in the target folder', 'info'); - return; - } - - const response = await fetch('/api/move_models_bulk', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_paths: movedPaths, - target_path: targetPath - }) - }); - - const result = await response.json(); - - if (!response.ok) { - throw new Error('Failed to move models'); - } - - // Display results with more details - if (result.success) { - if (result.failure_count > 0) { - // Some files failed to move - showToast(`Moved ${result.success_count} models, ${result.failure_count} failed`, 'warning'); - - // Log details about failures - console.log('Move operation results:', result.results); - - // Get list of failed files with reasons - const failedFiles = result.results - .filter(r => !r.success) - .map(r => { - const fileName = r.path.substring(r.path.lastIndexOf('/') + 1); - return `${fileName}: ${r.message}`; - }); - - // Show first few failures in a toast - 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); - } - } else { - // All files moved successfully - showToast(`Successfully moved ${result.success_count} models`, 'success'); - } - } else { - throw new Error(result.message || 'Failed to move models'); - } - } } export const moveManager = new MoveManager(); diff --git a/static/js/utils/VirtualScroller.js b/static/js/utils/VirtualScroller.js index bbdc73b6..0d614211 100644 --- a/static/js/utils/VirtualScroller.js +++ b/static/js/utils/VirtualScroller.js @@ -164,23 +164,6 @@ export class VirtualScroller { // Calculate the left offset to center the grid within the content area this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2); - - // Log layout info - console.log('Virtual Scroll Layout:', { - containerWidth, - availableContentWidth, - actualGridWidth, - columnsCount: this.columnsCount, - itemWidth: this.itemWidth, - itemHeight: this.itemHeight, - leftOffset: this.leftOffset, - paddingLeft, - paddingRight, - displayDensity, - maxColumns, - baseCardWidth, - rowGap: this.rowGap - }); // Update grid element max-width to match available width this.gridElement.style.maxWidth = `${actualGridWidth}px`; diff --git a/static/js/utils/modalUtils.js b/static/js/utils/modalUtils.js index 512b0c31..19a0edf5 100644 --- a/static/js/utils/modalUtils.js +++ b/static/js/utils/modalUtils.js @@ -43,7 +43,6 @@ export async function confirmDelete() { export function closeDeleteModal() { modalManager.closeModal('deleteModal'); pendingDeletePath = null; - pendingModelType = null; } // Functions for the exclude modal @@ -67,7 +66,6 @@ export function showExcludeModal(filePath) { export function closeExcludeModal() { modalManager.closeModal('excludeModal'); pendingExcludePath = null; - pendingExcludeModelType = null; } export async function confirmExclude() { diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index 1d03a01f..0b46245b 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -1,4 +1,4 @@ -import { state } from '../state/index.js'; +import { state, getCurrentPageState } from '../state/index.js'; import { resetAndReload } from '../api/loraApi.js'; import { getStorageItem, setStorageItem } from './storageHelpers.js'; import { NODE_TYPES, NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js'; @@ -626,7 +626,8 @@ export function updateFolderTags(folders) { if (!folderTagsContainer) return; // Keep track of currently selected folder - const currentFolder = this.pageState.activeFolder; + const pageState = getCurrentPageState(); + const currentFolder = pageState.activeFolder; // Create HTML for folder tags const tagsHTML = folders.map(folder => { From 08265a85ec88304ca154412982909162dd63c107 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Fri, 25 Jul 2025 15:10:03 +0800 Subject: [PATCH 05/20] refactor: Include new file path in response after moving model --- py/routes/lora_routes.py | 2 +- static/js/api/baseModelApi.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/py/routes/lora_routes.py b/py/routes/lora_routes.py index f88d03ec..84776eb5 100644 --- a/py/routes/lora_routes.py +++ b/py/routes/lora_routes.py @@ -345,7 +345,7 @@ class LoraRoutes(BaseModelRoutes): success = await self.service.scanner.move_model(file_path, target_path) if success: - return web.json_response({'success': True}) + return web.json_response({'success': True, 'new_file_path': target_file_path}) else: return web.Response(text='Failed to move model', status=500) diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 50a1af16..692a83db 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -515,7 +515,7 @@ class ModelApiClient { // Return new file path if move succeeded if (result.success) { - return targetPath; + return result.new_file_path; } return null; } From a7d9255c2cd4f558301c891a6bab4101c18be196 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Fri, 25 Jul 2025 15:35:16 +0800 Subject: [PATCH 06/20] refactor: Replace direct model metadata API calls with unified model API client --- static/js/components/alphabet/AlphabetBar.js | 2 +- static/js/components/initialization.js | 1 - static/js/components/shared/ModelMetadata.js | 3 --- static/js/components/shared/ModelModal.js | 10 +++------- static/js/components/shared/ModelTags.js | 8 ++------ static/js/components/shared/PresetTags.js | 4 ++-- static/js/components/shared/TriggerWords.js | 4 ++-- 7 files changed, 10 insertions(+), 22 deletions(-) diff --git a/static/js/components/alphabet/AlphabetBar.js b/static/js/components/alphabet/AlphabetBar.js index be1fa0f3..c56a2d5b 100644 --- a/static/js/components/alphabet/AlphabetBar.js +++ b/static/js/components/alphabet/AlphabetBar.js @@ -1,5 +1,5 @@ // AlphabetBar.js - Component for alphabet filtering -import { getCurrentPageState, setCurrentPageType } from '../../state/index.js'; +import { getCurrentPageState } from '../../state/index.js'; import { getStorageItem, setStorageItem } from '../../utils/storageHelpers.js'; import { resetAndReload } from '../../api/loraApi.js'; diff --git a/static/js/components/initialization.js b/static/js/components/initialization.js index 78c48016..e7b6818f 100644 --- a/static/js/components/initialization.js +++ b/static/js/components/initialization.js @@ -4,7 +4,6 @@ */ import { appCore } from '../core.js'; import { getSessionItem, setSessionItem } from '../utils/storageHelpers.js'; -import { state, getCurrentPageState } from '../state/index.js'; class InitializationManager { constructor() { diff --git a/static/js/components/shared/ModelMetadata.js b/static/js/components/shared/ModelMetadata.js index 27f433df..17b33817 100644 --- a/static/js/components/shared/ModelMetadata.js +++ b/static/js/components/shared/ModelMetadata.js @@ -4,9 +4,6 @@ */ import { showToast } from '../../utils/uiHelpers.js'; import { BASE_MODELS } from '../../utils/constants.js'; -import { state } from '../../state/index.js'; -import { saveModelMetadata as saveLoraMetadata, renameLoraFile } from '../../api/loraApi.js'; -import { saveModelMetadata as saveCheckpointMetadata, renameCheckpointFile } from '../../api/checkpointApi.js'; import { getModelApiClient } from '../../api/baseModelApi.js'; /** diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js index 6fcdf419..954a6d78 100644 --- a/static/js/components/shared/ModelModal.js +++ b/static/js/components/shared/ModelModal.js @@ -13,8 +13,7 @@ import { setupFileNameEditing } from './ModelMetadata.js'; import { setupTagEditMode } from './ModelTags.js'; -import { saveModelMetadata as saveLoraMetadata } from '../../api/loraApi.js'; -import { saveModelMetadata as saveCheckpointMetadata } from '../../api/checkpointApi.js'; +import { getModelApiClient } from '../../api/baseModelApi.js'; import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js'; import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js'; import { parsePresets, renderPresetTags } from './PresetTags.js'; @@ -378,9 +377,7 @@ function setupLoraSpecificFields(filePath) { currentPresets[key] = parseFloat(value); const newPresetsJson = JSON.stringify(currentPresets); - await saveLoraMetadata(filePath, { - usage_tips: newPresetsJson - }); + await getModelApiClient().saveModelMetadata(filePath, { usage_tips: newPresetsJson }); presetTags.innerHTML = renderPresetTags(currentPresets); @@ -406,8 +403,7 @@ function setupLoraSpecificFields(filePath) { async function saveNotes(filePath, modelType) { const content = document.querySelector('.notes-content').textContent; try { - const saveFunction = modelType === 'lora' ? saveLoraMetadata : saveCheckpointMetadata; - await saveFunction(filePath, { notes: content }); + await getModelApiClient().saveModelMetadata(filePath, { notes: content }); showToast('Notes saved successfully', 'success'); } catch (error) { diff --git a/static/js/components/shared/ModelTags.js b/static/js/components/shared/ModelTags.js index 60da2244..c58889d2 100644 --- a/static/js/components/shared/ModelTags.js +++ b/static/js/components/shared/ModelTags.js @@ -3,9 +3,7 @@ * Module for handling model tag editing functionality - 共享版本 */ import { showToast } from '../../utils/uiHelpers.js'; -import { saveModelMetadata as saveLoraMetadata } from '../../api/loraApi.js'; -import { saveModelMetadata as saveCheckpointMetadata } from '../../api/checkpointApi.js'; -import { state } from '../../state/index.js'; +import { getModelApiClient } from '../../api/baseModelApi.js'; // Preset tag suggestions const PRESET_TAGS = [ @@ -165,10 +163,8 @@ async function saveTags() { } try { - const saveFunction = state.currentPageType === 'checkpoints' ? saveCheckpointMetadata : saveLoraMetadata; - // Save tags metadata - await saveFunction(filePath, { tags: tags }); + await getModelApiClient().saveModelMetadata(filePath, { tags: tags }); // Set flag to skip restoring original tags when exiting edit mode editBtn.dataset.skipRestore = "true"; diff --git a/static/js/components/shared/PresetTags.js b/static/js/components/shared/PresetTags.js index 12d75c1f..00d391ce 100644 --- a/static/js/components/shared/PresetTags.js +++ b/static/js/components/shared/PresetTags.js @@ -2,7 +2,7 @@ * PresetTags.js * Handles LoRA model preset parameter tags - Shared version */ -import { saveModelMetadata } from '../../api/loraApi.js'; +import { getModelApiClient } from '../../api/baseModelApi.js'; /** * Parse preset parameters @@ -58,7 +58,7 @@ window.removePreset = async function(key) { delete currentPresets[key]; const newPresetsJson = JSON.stringify(currentPresets); - await saveModelMetadata(filePath, { + await getModelApiClient().saveModelMetadata(filePath, { usage_tips: newPresetsJson }); diff --git a/static/js/components/shared/TriggerWords.js b/static/js/components/shared/TriggerWords.js index e011f700..f27db73d 100644 --- a/static/js/components/shared/TriggerWords.js +++ b/static/js/components/shared/TriggerWords.js @@ -4,7 +4,7 @@ * Moved to shared directory for consistency */ import { showToast, copyToClipboard } from '../../utils/uiHelpers.js'; -import { saveModelMetadata } from '../../api/loraApi.js'; +import { getModelApiClient } from '../../api/baseModelApi.js'; /** * Fetch trained words for a model @@ -610,7 +610,7 @@ async function saveTriggerWords() { try { // Special format for updating nested civitai.trainedWords - await saveModelMetadata(filePath, { + await getModelApiClient().saveModelMetadata(filePath, { civitai: { trainedWords: words } }); From 206c1bd69f0d7e87ba4559006c6c5e34b390d3e9 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Fri, 25 Jul 2025 16:10:16 +0800 Subject: [PATCH 07/20] Refactor modals.html into modular components --- py/utils/routes_common.py | 2 - static/js/components/alphabet/AlphabetBar.js | 2 +- templates/components/modals.html | 722 +----------------- .../components/modals/confirm_modals.html | 85 +++ .../modals/example_access_modal.html | 27 + templates/components/modals/help_modal.html | 131 ++++ .../modals/relink_civitai_modal.html | 33 + .../components/modals/settings_modal.html | 278 +++++++ .../components/modals/support_modal.html | 91 +++ templates/components/modals/update_modal.html | 64 ++ 10 files changed, 717 insertions(+), 718 deletions(-) create mode 100644 templates/components/modals/confirm_modals.html create mode 100644 templates/components/modals/example_access_modal.html create mode 100644 templates/components/modals/help_modal.html create mode 100644 templates/components/modals/relink_civitai_modal.html create mode 100644 templates/components/modals/settings_modal.html create mode 100644 templates/components/modals/support_modal.html create mode 100644 templates/components/modals/update_modal.html diff --git a/py/utils/routes_common.py b/py/utils/routes_common.py index 8f00106b..40b02cb5 100644 --- a/py/utils/routes_common.py +++ b/py/utils/routes_common.py @@ -551,8 +551,6 @@ class ModelRouteUtils: # Add to excluded models list scanner._excluded_models.append(file_path) - - await scanner._save_cache_to_disk() return web.json_response({ 'success': True, diff --git a/static/js/components/alphabet/AlphabetBar.js b/static/js/components/alphabet/AlphabetBar.js index c56a2d5b..723e3089 100644 --- a/static/js/components/alphabet/AlphabetBar.js +++ b/static/js/components/alphabet/AlphabetBar.js @@ -227,7 +227,7 @@ export class AlphabetBar { this.updateToggleIndicator(); // Trigger a reload with the new filter - resetAndReload(true); + resetAndReload(false); } /** diff --git a/templates/components/modals.html b/templates/components/modals.html index 7f152c90..8d163336 100644 --- a/templates/components/modals.html +++ b/templates/components/modals.html @@ -1,715 +1,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file +{% include 'components/modals/confirm_modals.html' %} +{% include 'components/modals/settings_modal.html' %} +{% include 'components/modals/support_modal.html' %} +{% include 'components/modals/update_modal.html' %} +{% include 'components/modals/help_modal.html' %} +{% include 'components/modals/relink_civitai_modal.html' %} +{% include 'components/modals/example_access_modal.html' %} \ No newline at end of file diff --git a/templates/components/modals/confirm_modals.html b/templates/components/modals/confirm_modals.html new file mode 100644 index 00000000..ba03493f --- /dev/null +++ b/templates/components/modals/confirm_modals.html @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/templates/components/modals/example_access_modal.html b/templates/components/modals/example_access_modal.html new file mode 100644 index 00000000..bfd56fb7 --- /dev/null +++ b/templates/components/modals/example_access_modal.html @@ -0,0 +1,27 @@ + + \ No newline at end of file diff --git a/templates/components/modals/help_modal.html b/templates/components/modals/help_modal.html new file mode 100644 index 00000000..0c1d1566 --- /dev/null +++ b/templates/components/modals/help_modal.html @@ -0,0 +1,131 @@ + + \ No newline at end of file diff --git a/templates/components/modals/relink_civitai_modal.html b/templates/components/modals/relink_civitai_modal.html new file mode 100644 index 00000000..b8358bcb --- /dev/null +++ b/templates/components/modals/relink_civitai_modal.html @@ -0,0 +1,33 @@ + + \ No newline at end of file diff --git a/templates/components/modals/settings_modal.html b/templates/components/modals/settings_modal.html new file mode 100644 index 00000000..a475a568 --- /dev/null +++ b/templates/components/modals/settings_modal.html @@ -0,0 +1,278 @@ + + \ No newline at end of file diff --git a/templates/components/modals/support_modal.html b/templates/components/modals/support_modal.html new file mode 100644 index 00000000..c2a3ab63 --- /dev/null +++ b/templates/components/modals/support_modal.html @@ -0,0 +1,91 @@ + + \ No newline at end of file diff --git a/templates/components/modals/update_modal.html b/templates/components/modals/update_modal.html new file mode 100644 index 00000000..e6e162ae --- /dev/null +++ b/templates/components/modals/update_modal.html @@ -0,0 +1,64 @@ + + \ No newline at end of file From e5871898807bd1f19f19bc467efe660ffe2ec86d Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Fri, 25 Jul 2025 16:36:07 +0800 Subject: [PATCH 08/20] Refactor modal.css into modular components --- static/css/components/modal.css | 1369 ----------------- static/css/components/modal/_base.css | 274 ++++ static/css/components/modal/delete-modal.css | 48 + .../components/modal/example-access-modal.css | 72 + static/css/components/modal/help-modal.css | 307 ++++ .../components/modal/relink-civitai-modal.css | 53 + .../css/components/modal/settings-modal.css | 485 ++++++ .../components/{ => modal}/support-modal.css | 0 static/css/components/modal/update-modal.css | 124 ++ static/css/style.css | 10 +- 10 files changed, 1371 insertions(+), 1371 deletions(-) delete mode 100644 static/css/components/modal.css create mode 100644 static/css/components/modal/_base.css create mode 100644 static/css/components/modal/delete-modal.css create mode 100644 static/css/components/modal/example-access-modal.css create mode 100644 static/css/components/modal/help-modal.css create mode 100644 static/css/components/modal/relink-civitai-modal.css create mode 100644 static/css/components/modal/settings-modal.css rename static/css/components/{ => modal}/support-modal.css (100%) create mode 100644 static/css/components/modal/update-modal.css diff --git a/static/css/components/modal.css b/static/css/components/modal.css deleted file mode 100644 index 031ed917..00000000 --- a/static/css/components/modal.css +++ /dev/null @@ -1,1369 +0,0 @@ -/* 修改 modal 基础样式 */ -.modal { - display: none; - position: fixed; - top: 48px; /* Start below the header */ - left: 0; - width: 100%; - height: calc(100% - 48px); /* Adjust height to exclude header */ - background: rgba(0, 0, 0, 0.2); /* 调整为更淡的半透明黑色 */ - z-index: var(--z-modal); - overflow: auto; /* Change from hidden to auto to allow scrolling */ -} - -/* 当模态窗口打开时,禁止body滚动 */ -body.modal-open { - position: fixed; - width: 100%; - padding-right: var(--scrollbar-width, 0px); /* 补偿滚动条消失导致的页面偏移 */ -} - -/* 修改 modal-content 样式 */ -.modal-content { - position: relative; - max-width: 800px; - height: auto; - max-height: calc(90vh - 48px); /* Adjust to account for header height */ - margin: 1rem auto; /* Keep reduced top margin */ - background: var(--lora-surface); - border-radius: var(--border-radius-base); - padding: var(--space-3); - border: 1px solid var(--lora-border); - box-shadow: - 0 4px 6px -1px rgba(0, 0, 0, 0.1), - 0 2px 4px -1px rgba(0, 0, 0, 0.06), - 0 10px 15px -3px rgba(0, 0, 0, 0.05); - overflow-y: auto; - overflow-x: hidden; /* 防止水平滚动条 */ -} - -/* 当 modal 打开时锁定 body */ -body.modal-open { - overflow: hidden !important; /* 覆盖 base.css 中的 scroll */ - padding-right: var(--scrollbar-width, 8px); /* 使用滚动条宽度作为补偿 */ -} - -/* Delete Modal specific styles */ - -.delete-message { - color: var(--text-color); - margin: var(--space-2) 0; -} - -/* Update delete modal styles */ -.delete-modal { - display: none; /* Set initial display to none */ - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.8); - z-index: var(--z-overlay); -} - -/* Add new style for when modal is shown */ -.delete-modal.show { - display: flex; - align-items: center; - justify-content: center; -} - -.delete-modal-content { - max-width: 500px; - width: 90%; - text-align: center; - margin: 0 auto; - position: relative; - animation: modalFadeIn 0.2s ease-out; -} - -.delete-model-info, -.exclude-model-info { - /* Update info display styling */ - background: var(--lora-surface); - border: 1px solid var(--lora-border); - border-radius: var(--border-radius-sm); - padding: var(--space-2); - margin: var(--space-2) 0; - color: var(--text-color); - word-break: break-all; - text-align: left; - line-height: 1.5; -} - -@keyframes modalFadeIn { - from { - opacity: 0; - transform: translateY(-20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.modal-actions { - display: flex; - gap: var(--space-2); - justify-content: center; - margin-top: var(--space-3); -} - -.cancel-btn, .delete-btn, .exclude-btn, .confirm-btn { - padding: 8px var(--space-2); - border-radius: 6px; - border: none; - cursor: pointer; - font-weight: 500; - min-width: 100px; -} - -.cancel-btn { - background: var(--lora-surface); - border: 1px solid var(--lora-border); - color: var(--text-color); -} - -.delete-btn { - background: var(--lora-error); - color: white; -} - -/* Style for exclude button - different from delete button */ -.exclude-btn, .confirm-btn { - background: var(--lora-accent, #4f46e5); - color: white; -} - -.cancel-btn:hover { - background: var(--lora-border); -} - -.delete-btn:hover { - opacity: 0.9; -} - -.exclude-btn:hover, .confirm-btn:hover { - opacity: 0.9; - background: oklch(from var(--lora-accent, #4f46e5) l c h / 85%); -} - -.modal-content h2 { - color: var(--text-color); - margin-bottom: var(--space-1); - font-size: 1.5em; -} - -.close { - position: absolute; - top: var(--space-2); - right: var(--space-2); - background: transparent; - border: none; - color: var(--text-color); - font-size: 1.5em; - cursor: pointer; - opacity: 0.7; - transition: opacity 0.2s; -} - -.close:hover { - opacity: 1; -} - -/* Update Modal specific styles */ -.update-actions { - display: flex; - flex-direction: column; - gap: var(--space-2); - align-items: stretch; - flex-wrap: nowrap; -} - -.update-link { - color: var(--lora-accent); - text-decoration: none; - display: flex; - align-items: center; - gap: 8px; - font-size: 0.95em; -} - -.update-link:hover { - text-decoration: underline; -} - -/* Update progress styles */ -.update-progress { - background: rgba(0, 0, 0, 0.03); - border: 1px solid var(--lora-border); - border-radius: var(--border-radius-sm); - padding: var(--space-2); - margin: var(--space-2) 0; -} - -[data-theme="dark"] .update-progress { - background: rgba(255, 255, 255, 0.03); -} - -.progress-info { - display: flex; - flex-direction: column; - gap: var(--space-1); -} - -.progress-text { - font-size: 0.9em; - color: var(--text-color); - opacity: 0.8; -} - -.progress-bar { - width: 100%; - height: 8px; - background-color: rgba(0, 0, 0, 0.1); - border-radius: 4px; - overflow: hidden; -} - -[data-theme="dark"] .progress-bar { - background-color: rgba(255, 255, 255, 0.1); -} - -.progress-fill { - height: 100%; - background-color: var(--lora-accent); - width: 0%; - transition: width 0.3s ease; - border-radius: 4px; -} - -/* Update button states */ -#updateBtn { - min-width: 120px; -} - -#updateBtn.updating { - background-color: var(--lora-warning); - cursor: not-allowed; -} - -#updateBtn.success { - background-color: var(--lora-success); -} - -#updateBtn.error { - background-color: var(--lora-error); -} - -/* Settings styles */ -.settings-toggle { - 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.2s ease; -} - -.settings-toggle:hover { - background: var(--lora-accent); - color: white; - transform: translateY(-2px); -} - -.settings-modal { - max-width: 650px; /* Further increased from 600px for more space */ -} - -/* Settings Links */ -.settings-links { - margin-top: var(--space-3); - padding-top: var(--space-2); - border-top: 1px solid var(--lora-border); - display: flex; - gap: var(--space-2); - justify-content: center; -} - -.settings-link { - 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.2s ease; - text-decoration: none; - position: relative; -} - -.settings-link:hover { - background: var(--lora-accent); - color: white; - transform: translateY(-2px); -} - -.settings-link i { - font-size: 1.1em; -} - -/* Tooltip styles */ -.settings-link::after { - content: attr(title); - position: absolute; - bottom: calc(100% + 8px); - left: 50%; - transform: translateX(-50%); - background: rgba(0, 0, 0, 0.8); - color: white; - padding: 4px 8px; - border-radius: 4px; - font-size: 0.8em; - white-space: nowrap; - opacity: 0; - visibility: hidden; - transition: opacity 0.2s, visibility 0.2s; - pointer-events: none; -} - -.settings-link:hover::after { - opacity: 1; - visibility: visible; -} - -/* Responsive adjustment */ -@media (max-width: 480px) { - .settings-links { - flex-wrap: wrap; - } -} - -/* API key input specific styles */ -.api-key-input { - width: 100%; /* Take full width of parent */ - position: relative; - display: flex; - align-items: center; -} - -.api-key-input input { - width: 100%; - padding: 6px 40px 6px 10px; /* Add left padding */ - height: 32px; - border-radius: var(--border-radius-xs); - border: 1px solid var(--border-color); - background-color: var(--lora-surface); - color: var(--text-color); -} - -.api-key-input .toggle-visibility { - position: absolute; - right: 8px; - background: none; - border: none; - color: var(--text-color); - opacity: 0.6; - cursor: pointer; - padding: 4px 8px; -} - -.api-key-input .toggle-visibility:hover { - opacity: 1; -} - -.input-help { - font-size: 0.85em; - color: var(--text-color); - opacity: 0.7; - margin-top: 8px; /* Space between control and help */ - line-height: 1.4; - width: 100%; /* Full width */ -} - -/* 统一各个 section 的样式 */ -.support-section, -.changelog-section, -.update-info, -.info-item, -.path-preview { - background: rgba(0, 0, 0, 0.03); - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: var(--border-radius-sm); - padding: var(--space-2); -} - -/* 深色主题统一样式 */ -[data-theme="dark"] .modal-content { - background: var(--lora-surface); - border: 1px solid var(--lora-border); -} - -[data-theme="dark"] .support-section, -[data-theme="dark"] .changelog-section, -[data-theme="dark"] .update-info, -[data-theme="dark"] .info-item, -[data-theme="dark"] .path-preview { - background: rgba(255, 255, 255, 0.03); - border: 1px solid var(--lora-border); -} - -/* Settings Styles */ -.settings-section { - margin-top: var(--space-3); - border-top: 1px solid var(--lora-border); - padding-top: var(--space-2); -} - -.settings-section h3 { - font-size: 1.1em; - margin-bottom: var(--space-2); - color: var(--text-color); - opacity: 0.9; -} - -.setting-item { - display: flex; - flex-direction: column; /* Changed to column for help text placement */ - margin-bottom: var(--space-3); /* Increased to provide more spacing between items */ - padding: var(--space-1); - border-radius: var(--border-radius-xs); -} - -.setting-item:hover { - background: rgba(0, 0, 0, 0.02); -} - -[data-theme="dark"] .setting-item:hover { - background: rgba(255, 255, 255, 0.05); -} - -/* Control row with label and input together */ -.setting-row { - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - width: 100%; -} - -.setting-info { - margin-bottom: 0; - width: 35%; /* Increased from 30% to prevent wrapping */ - flex-shrink: 0; /* Prevent shrinking */ -} - -.setting-info label { - display: block; - font-weight: 500; - margin-bottom: 0; - white-space: nowrap; /* Prevent label wrapping */ -} - -.setting-control { - width: 60%; /* Decreased slightly from 65% */ - margin-bottom: 0; - display: flex; - justify-content: flex-end; /* Right-align all controls */ -} - -/* Select Control Styles */ -.select-control { - width: 100%; - display: flex; - justify-content: flex-end; -} - -.select-control select { - width: 100%; - max-width: 100%; /* Increased from 200px */ - padding: 6px 10px; - border-radius: var(--border-radius-xs); - border: 1px solid var(--border-color); - background-color: var(--lora-surface); - color: var(--text-color); - font-size: 0.95em; - height: 32px; -} - -/* Fix dark theme select dropdown text color */ -[data-theme="dark"] .select-control select { - background-color: rgba(30, 30, 30, 0.9); - color: var(--text-color); -} - -[data-theme="dark"] .select-control select option { - background-color: #2d2d2d; - color: var(--text-color); -} - -.select-control select:focus { - border-color: var(--lora-accent); - outline: none; -} - -/* Toggle Switch */ -.toggle-switch { - position: relative; - display: inline-block; - width: 50px; - height: 24px; - cursor: pointer; - margin-left: auto; /* Push to right side */ -} - -.toggle-switch input { - opacity: 0; - width: 0; - height: 0; -} - -.toggle-slider { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--border-color); - transition: .3s; - border-radius: 24px; -} - -.toggle-slider:before { - position: absolute; - content: ""; - height: 18px; - width: 18px; - left: 3px; - bottom: 3px; - background-color: white; - transition: .3s; - border-radius: 50%; -} - -input:checked + .toggle-slider { - background-color: var(--lora-accent); -} - -input:checked + .toggle-slider:before { - transform: translateX(26px); -} - -.toggle-label { - margin-left: 60px; - line-height: 24px; -} - -/* Add small animation for the toggle */ -.toggle-slider:active:before { - width: 22px; -} - -/* Blur effect for NSFW content */ -.nsfw-blur { - filter: blur(12px); - transition: filter 0.3s ease; -} - -.nsfw-blur:hover { - filter: blur(8px); -} - -/* Example Images Settings Styles */ -.download-buttons { - justify-content: flex-start; - gap: var(--space-2); -} - -.primary-btn { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - background-color: var(--lora-accent); - color: var(--lora-text); - border: none; - border-radius: var(--border-radius-sm); - cursor: pointer; - transition: background-color 0.2s; - font-size: 0.95em; -} - -.primary-btn:hover { - background-color: oklch(from var(--lora-accent) l c h / 85%); - color: var(--lora-text); -} - -/* Secondary button styles */ -.secondary-btn { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - background-color: var(--card-bg); - color: var (--text-color); - border: 1px solid var(--border-color); - border-radius: var(--border-radius-sm); - cursor: pointer; - transition: all 0.2s; - font-size: 0.95em; -} - -.secondary-btn:hover { - background-color: var(--border-color); - color: var(--text-color); -} - -/* Disabled button styles */ -.primary-btn.disabled { - opacity: 0.5; - cursor: not-allowed; - background-color: var(--lora-accent); - color: var(--lora-text); - pointer-events: none; -} - -.secondary-btn.disabled { - opacity: 0.5; - cursor: not-allowed; - pointer-events: none; -} - -.restart-required-icon { - color: var(--lora-warning); - margin-left: 5px; - font-size: 0.85em; - vertical-align: text-bottom; -} - -/* Dark theme specific button adjustments */ -[data-theme="dark"] .primary-btn:hover { - background-color: oklch(from var(--lora-accent) l c h / 75%); -} - -[data-theme="dark"] .secondary-btn { - background-color: var(--lora-surface); -} - -[data-theme="dark"] .secondary-btn:hover { - background-color: oklch(35% 0.02 256 / 0.98); -} - -.primary-btn.disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.path-control { - display: flex; - gap: 8px; - align-items: center; - width: 100%; -} - -.path-control input[type="text"] { - flex: 1; - padding: 6px 10px; - border-radius: var(--border-radius-xs); - border: 1px solid var(--border-color); - background-color: var(--lora-surface); - color: var (--text-color); - font-size: 0.95em; - height: 32px; -} - -.primary-btn.disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* Add styles for delete preview image */ -.delete-preview { - max-width: 150px; - margin: 0 auto var(--space-2); - overflow: hidden; -} - -.delete-preview img { - width: 100%; - height: auto; - max-height: 150px; - object-fit: contain; - border-radius: var(--border-radius-sm); -} - -.delete-info { - text-align: center; -} - -.delete-info h3 { - margin-bottom: var(--space-1); - word-break: break-word; -} - -.delete-info p { - margin: var(--space-1) 0; - font-size: 0.9em; - opacity: 0.8; -} - -.delete-note { - font-size: 0.85em; - color: var(--text-color); - opacity: 0.7; - font-style: italic; - margin-top: var(--space-1); - text-align: center; -} - -/* Add styles for markdown elements in changelog */ -.changelog-item ul { - padding-left: 20px; - margin-top: 8px; -} - -.changelog-item li { - margin-bottom: 6px; - line-height: 1.4; -} - -.changelog-item strong { - font-weight: 600; -} - -.changelog-item em { - font-style: italic; -} - -.changelog-item code { - background: rgba(0, 0, 0, 0.05); - padding: 2px 4px; - border-radius: 3px; - font-family: monospace; - font-size: 0.9em; -} - -[data-theme="dark"] .changelog-item code { - background: rgba(255, 255, 255, 0.1); -} - -.changelog-item a { - color: var(--lora-accent); - text-decoration: none; -} - -.changelog-item a:hover { - text-decoration: underline; -} - -/* Add warning text style for settings */ -.warning-text { - color: var(--lora-warning, #e67e22); - font-weight: 500; -} - -[data-theme="dark"] .warning-text { - color: var(--lora-warning, #f39c12); -} - -/* Add styles for list description */ -.list-description { - margin: 8px 0; - padding-left: 20px; - font-size: 0.9em; -} - -.list-description li { - margin-bottom: 4px; -} - -/* Help Modal styles */ -.help-modal { - max-width: 850px; -} - -.help-header { - display: flex; - align-items: center; - margin-bottom: var(--space-2); -} - -.modal-help-icon { - font-size: 24px; - color: var(--lora-accent); - margin-right: var(--space-2); - vertical-align: text-bottom; -} - -/* Tab navigation styles */ -.help-tabs { - display: flex; - border-bottom: 1px solid var(--lora-border); - margin-bottom: var(--space-2); - gap: 8px; -} - -.tab-btn { - padding: 8px 16px; - background: transparent; - border: none; - border-bottom: 2px solid transparent; - color: var(--text-color); - cursor: pointer; - font-weight: 500; - transition: all 0.2s; - opacity: 0.7; -} - -.tab-btn:hover { - background-color: rgba(0, 0, 0, 0.05); - opacity: 0.9; -} - -.tab-btn.active { - color: var(--lora-accent); - border-bottom: 2px solid var(--lora-accent); - opacity: 1; -} - -/* Add styles for tab with new content indicator */ -.tab-btn.has-new-content { - position: relative; -} - -.tab-btn.has-new-content::after { - content: ""; - position: absolute; - top: 4px; - right: 4px; - width: 8px; - height: 8px; - background-color: var(--lora-accent); - border-radius: 50%; - animation: pulse 2s infinite; -} - -@keyframes pulse { - 0% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.7; transform: scale(1.1); } - 100% { opacity: 1; transform: scale(1); } -} - -/* Tab content styles */ -.help-content { - padding: var(--space-1) 0; - overflow-y: auto; -} - -.tab-pane { - display: none; -} - -.tab-pane.active { - display: block; -} - -.help-text { - margin: var(--space-2) 0; -} - -.help-text ul { - padding-left: 20px; - margin-top: 8px; -} - -.help-text li { - margin-bottom: 8px; -} - -/* Documentation link styles */ -.docs-section { - margin-bottom: var(--space-3); -} - -.docs-section h4 { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: var(--space-1); -} - -.docs-links { - list-style-type: none; - padding-left: var(--space-3); -} - -.docs-links li { - margin-bottom: var(--space-1); - position: relative; -} - -.docs-links li:before { - content: "•"; - position: absolute; - left: -15px; - color: var(--lora-accent); -} - -.docs-links a { - color: var(--lora-accent); - text-decoration: none; - transition: color 0.2s; -} - -.docs-links a:hover { - text-decoration: underline; -} - -/* New content badge styles */ -.new-content-badge { - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 0.7em; - font-weight: 600; - background-color: var(--lora-accent); - color: var(--lora-text); - padding: 2px 6px; - border-radius: 10px; - margin-left: 8px; - vertical-align: middle; - animation: fadeIn 0.5s ease-in-out; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.new-content-badge.inline { - font-size: 0.65em; - padding: 1px 4px; - margin-left: 6px; - border-radius: 8px; -} - -/* Dark theme adjustments for new content badge */ -[data-theme="dark"] .new-content-badge { - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); -} - -/* Update video list styles */ -.video-list { - display: flex; - flex-direction: column; - gap: var(--space-3); -} - -.video-item { - display: flex; - flex-direction: column; -} - -.video-info { - padding: var(--space-1); -} - -.video-info h4 { - margin-bottom: var(--space-1); -} - -.video-info p { - font-size: 0.9em; - opacity: 0.8; -} - -/* Dark theme adjustments */ -[data-theme="dark"] .tab-btn:hover { - background-color: rgba(255, 255, 255, 0.05); -} - -/* Update date badge styles */ -.update-date-badge { - display: inline-flex; - align-items: center; - font-size: 0.75em; - font-weight: 500; - background-color: var(--lora-accent); - color: var(--lora-text); - padding: 4px 8px; - border-radius: 12px; - margin-left: 10px; - vertical-align: middle; - animation: fadeIn 0.5s ease-in-out; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.update-date-badge i { - margin-right: 5px; - font-size: 0.9em; -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(-5px); } - to { opacity: 1; transform: translateY(0); } -} - -/* Dark theme adjustments */ -[data-theme="dark"] .update-date-badge { - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); -} - -/* Re-link to Civitai Modal styles */ -.warning-box { - background-color: rgba(255, 193, 7, 0.1); - border: 1px solid rgba(255, 193, 7, 0.5); - border-radius: var(--border-radius-sm); - padding: var(--space-2); - margin-bottom: var(--space-3); -} - -.warning-box i { - color: var(--lora-warning); - margin-right: var(--space-1); -} - -.warning-box ul { - padding-left: 20px; - margin: var(--space-1) 0; -} - -.warning-box li { - margin-bottom: 4px; -} - -.input-group { - display: flex; - flex-direction: column; - margin-bottom: var(--space-2); -} - -.input-group label { - margin-bottom: var(--space-1); - font-weight: 500; -} - -.input-group input { - padding: 8px 12px; - border-radius: var(--border-radius-xs); - border: 1px solid var(--border-color); - background-color: var(--lora-surface); - color: var(--text-color); -} - -.input-error { - color: var(--lora-error); - font-size: 0.9em; - min-height: 20px; - margin-top: 4px; -} - -[data-theme="dark"] .warning-box { - background-color: rgba(255, 193, 7, 0.05); - border-color: rgba(255, 193, 7, 0.3); -} - -/* Privacy-friendly video embed styles */ -.video-container { - position: relative; - width: 100%; - padding-bottom: 56.25%; /* 16:9 aspect ratio */ - height: 0; - margin-bottom: var(--space-2); - border-radius: var(--border-radius-sm); - overflow: hidden; - background-color: rgba(0, 0, 0, 0.05); -} - -.video-thumbnail { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: center; -} - -.video-thumbnail img { - width: 100%; - height: 100%; - object-fit: cover; - transition: filter 0.2s ease; -} - -.video-play-overlay { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.5); - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - transition: opacity 0.2s ease; -} - -/* External link button styles */ -.external-link-btn { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 20px; - border-radius: var(--border-radius-sm); - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - background-color: var(--lora-accent); - color: white; - text-decoration: none; - border: none; -} - -.external-link-btn:hover { - background-color: oklch(from var(--lora-accent) l c h / 85%); -} - -.video-thumbnail i { - font-size: 1.2em; -} - -/* Smaller video container for the updates tab */ -.video-item .video-container { - padding-bottom: 40%; /* Shorter height for the playlist */ -} - -/* Dark theme adjustments */ -[data-theme="dark"] .video-container { - background-color: rgba(255, 255, 255, 0.03); -} - -/* Example Access Modal */ -.example-access-modal { - max-width: 550px; - text-align: center; -} - -.example-access-options { - display: flex; - flex-direction: column; - gap: var(--space-2); - margin: var(--space-3) 0; -} - -.example-option-btn { - display: flex; - flex-direction: column; - align-items: center; - padding: var(--space-2); - border-radius: var(--border-radius-sm); - border: 1px solid var(--lora-border); - background-color: var(--lora-surface); - cursor: pointer; - transition: all 0.2s; -} - -.example-option-btn:hover { - transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); - border-color: var(--lora-accent); -} - -.example-option-btn i { - font-size: 2em; - margin-bottom: var(--space-1); - color: var(--lora-accent); -} - -.option-title { - font-weight: 500; - margin-bottom: 4px; - font-size: 1.1em; -} - -.option-desc { - font-size: 0.9em; - opacity: 0.8; -} - -.example-option-btn.disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.example-option-btn.disabled i { - color: var(--text-color); - opacity: 0.5; -} - -.modal-footer-note { - font-size: 0.9em; - opacity: 0.7; - margin-top: var(--space-2); - display: flex; - align-items: center; - justify-content: center; - gap: 8px; -} - -/* Dark theme adjustments */ -[data-theme="dark"] .example-option-btn:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); -} - -/* Path Template Settings Styles */ -.template-preview { - background: rgba(0, 0, 0, 0.03); - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: var(--border-radius-xs); - padding: var(--space-1); - margin-top: 8px; - font-family: monospace; - font-size: 1.1em; - color: var(--lora-accent); - display: none; -} - -[data-theme="dark"] .template-preview { - background: rgba(255, 255, 255, 0.03); - border: 1px solid var(--lora-border); -} - -.template-preview:before { - content: "Preview: "; - opacity: 0.7; - color: var(--text-color); - font-family: inherit; -} - -/* Base Model Mappings Styles - Updated to match other settings */ -.mappings-container { - border: 1px solid var(--lora-border); - border-radius: var(--border-radius-sm); - padding: var(--space-2); - background: rgba(0, 0, 0, 0.02); - margin-top: 8px; /* Add consistent spacing */ -} - -[data-theme="dark"] .mappings-container { - background: rgba(255, 255, 255, 0.02); -} - -.add-mapping-btn { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - background: var(--lora-accent); - color: white; - border: none; - border-radius: var(--border-radius-xs); - cursor: pointer; - font-size: 0.9em; - transition: all 0.2s; - height: 32px; /* Match other control heights */ -} - -.add-mapping-btn:hover { - background: oklch(from var(--lora-accent) l c h / 85%); -} - -.mapping-row { - margin-bottom: var(--space-2); -} - -.mapping-row:last-child { - margin-bottom: 0; -} - -.mapping-controls { - display: grid; - grid-template-columns: 1fr 1fr auto; - gap: var(--space-1); - align-items: center; -} - -.base-model-select, -.path-value-input { - padding: 6px 10px; - border-radius: var(--border-radius-xs); - border: 1px solid var(--border-color); - background-color: var(--lora-surface); - color: var(--text-color); - font-size: 0.9em; - height: 32px; -} - -.path-value-input { - height: 18px; -} - -.base-model-select:focus, -.path-value-input:focus { - border-color: var(--lora-accent); - outline: none; - box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1); -} - -.remove-mapping-btn { - width: 32px; - height: 32px; - border-radius: var(--border-radius-xs); - border: 1px solid var(--lora-error); - background: transparent; - color: var(--lora-error); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; -} - -.remove-mapping-btn:hover { - background: var(--lora-error); - color: white; -} - -.mapping-empty-state { - text-align: center; - padding: var(--space-3); - color: var(--text-color); - opacity: 0.6; - font-style: italic; -} - -/* Responsive adjustments for mapping controls */ -@media (max-width: 768px) { - .mapping-controls { - grid-template-columns: 1fr; - gap: 8px; - } - - .remove-mapping-btn { - width: 100%; - height: 36px; - justify-self: stretch; - } -} - -/* Dark theme specific adjustments */ -[data-theme="dark"] .base-model-select, -[data-theme="dark"] .path-value-input { - background-color: rgba(30, 30, 30, 0.9); -} - -[data-theme="dark"] .base-model-select option { - background-color: #2d2d2d; - color: var(--text-color); -} \ No newline at end of file diff --git a/static/css/components/modal/_base.css b/static/css/components/modal/_base.css new file mode 100644 index 00000000..cfc172f9 --- /dev/null +++ b/static/css/components/modal/_base.css @@ -0,0 +1,274 @@ +/* modal 基础样式 */ +.modal { + display: none; + position: fixed; + top: 48px; /* Start below the header */ + left: 0; + width: 100%; + height: calc(100% - 48px); /* Adjust height to exclude header */ + background: rgba(0, 0, 0, 0.2); /* 调整为更淡的半透明黑色 */ + z-index: var(--z-modal); + overflow: auto; /* Change from hidden to auto to allow scrolling */ +} + +/* 当模态窗口打开时,禁止body滚动 */ +body.modal-open { + position: fixed; + width: 100%; + padding-right: var(--scrollbar-width, 0px); /* 补偿滚动条消失导致的页面偏移 */ +} + +/* modal-content 样式 */ +.modal-content { + position: relative; + max-width: 800px; + height: auto; + max-height: calc(90vh - 48px); /* Adjust to account for header height */ + margin: 1rem auto; /* Keep reduced top margin */ + background: var(--lora-surface); + border-radius: var(--border-radius-base); + padding: var(--space-3); + border: 1px solid var(--lora-border); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06), + 0 10px 15px -3px rgba(0, 0, 0, 0.05); + overflow-y: auto; + overflow-x: hidden; /* 防止水平滚动条 */ +} + +/* 当 modal 打开时锁定 body */ +body.modal-open { + overflow: hidden !important; /* 覆盖 base.css 中的 scroll */ + padding-right: var(--scrollbar-width, 8px); /* 使用滚动条宽度作为补偿 */ +} + +@keyframes modalFadeIn { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.modal-actions { + display: flex; + gap: var(--space-2); + justify-content: center; + margin-top: var(--space-3); +} + +.cancel-btn, .delete-btn, .exclude-btn, .confirm-btn { + padding: 8px var(--space-2); + border-radius: 6px; + border: none; + cursor: pointer; + font-weight: 500; + min-width: 100px; +} + +.cancel-btn { + background: var(--lora-surface); + border: 1px solid var(--lora-border); + color: var(--text-color); +} + +.delete-btn { + background: var(--lora-error); + color: white; +} + +/* Style for exclude button - different from delete button */ +.exclude-btn, .confirm-btn { + background: var(--lora-accent, #4f46e5); + color: white; +} + +.cancel-btn:hover { + background: var(--lora-border); +} + +.delete-btn:hover { + opacity: 0.9; +} + +.exclude-btn:hover, .confirm-btn:hover { + opacity: 0.9; + background: oklch(from var(--lora-accent, #4f46e5) l c h / 85%); +} + +.modal-content h2 { + color: var(--text-color); + margin-bottom: var(--space-1); + font-size: 1.5em; +} + +.close { + position: absolute; + top: var(--space-2); + right: var(--space-2); + background: transparent; + border: none; + color: var(--text-color); + font-size: 1.5em; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.2s; +} + +.close:hover { + opacity: 1; +} + +/* 统一各个 section 的样式 */ +.support-section, +.changelog-section, +.update-info, +.info-item, +.path-preview { + background: rgba(0, 0, 0, 0.03); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: var(--border-radius-sm); + padding: var(--space-2); +} + +/* 深色主题统一样式 */ +[data-theme="dark"] .modal-content { + background: var(--lora-surface); + border: 1px solid var(--lora-border); +} + +[data-theme="dark"] .support-section, +[data-theme="dark"] .changelog-section, +[data-theme="dark"] .update-info, +[data-theme="dark"] .info-item, +[data-theme="dark"] .path-preview { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--lora-border); +} + +.primary-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background-color: var(--lora-accent); + color: var(--lora-text); + border: none; + border-radius: var(--border-radius-sm); + cursor: pointer; + transition: background-color 0.2s; + font-size: 0.95em; +} + +.primary-btn:hover { + background-color: oklch(from var(--lora-accent) l c h / 85%); + color: var(--lora-text); +} + +/* Secondary button styles */ +.secondary-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background-color: var(--card-bg); + color: var (--text-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + cursor: pointer; + transition: all 0.2s; + font-size: 0.95em; +} + +.secondary-btn:hover { + background-color: var(--border-color); + color: var(--text-color); +} + +/* Disabled button styles */ +.primary-btn.disabled { + opacity: 0.5; + cursor: not-allowed; + background-color: var(--lora-accent); + color: var(--lora-text); + pointer-events: none; +} + +.secondary-btn.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +.restart-required-icon { + color: var(--lora-warning); + margin-left: 5px; + font-size: 0.85em; + vertical-align: text-bottom; +} + +/* Dark theme specific button adjustments */ +[data-theme="dark"] .primary-btn:hover { + background-color: oklch(from var(--lora-accent) l c h / 75%); +} + +[data-theme="dark"] .secondary-btn { + background-color: var(--lora-surface); +} + +[data-theme="dark"] .secondary-btn:hover { + background-color: oklch(35% 0.02 256 / 0.98); +} + +.primary-btn.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.primary-btn.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Add styles for delete preview image */ +.delete-preview { + max-width: 150px; + margin: 0 auto var(--space-2); + overflow: hidden; +} + +.delete-preview img { + width: 100%; + height: auto; + max-height: 150px; + object-fit: contain; + border-radius: var(--border-radius-sm); +} + +.delete-info { + text-align: center; +} + +.delete-info h3 { + margin-bottom: var(--space-1); + word-break: break-word; +} + +.delete-info p { + margin: var(--space-1) 0; + font-size: 0.9em; + opacity: 0.8; +} + +.delete-note { + font-size: 0.85em; + color: var(--text-color); + opacity: 0.7; + font-style: italic; + margin-top: var(--space-1); + text-align: center; +} \ No newline at end of file diff --git a/static/css/components/modal/delete-modal.css b/static/css/components/modal/delete-modal.css new file mode 100644 index 00000000..7a1334a4 --- /dev/null +++ b/static/css/components/modal/delete-modal.css @@ -0,0 +1,48 @@ +/* Delete Modal specific styles */ + +.delete-message { + color: var(--text-color); + margin: var(--space-2) 0; +} + +/* Update delete modal styles */ +.delete-modal { + display: none; /* Set initial display to none */ + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + z-index: var(--z-overlay); +} + +/* Add new style for when modal is shown */ +.delete-modal.show { + display: flex; + align-items: center; + justify-content: center; +} + +.delete-modal-content { + max-width: 500px; + width: 90%; + text-align: center; + margin: 0 auto; + position: relative; + animation: modalFadeIn 0.2s ease-out; +} + +.delete-model-info, +.exclude-model-info { + /* Update info display styling */ + background: var(--lora-surface); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + padding: var(--space-2); + margin: var(--space-2) 0; + color: var(--text-color); + word-break: break-all; + text-align: left; + line-height: 1.5; +} \ No newline at end of file diff --git a/static/css/components/modal/example-access-modal.css b/static/css/components/modal/example-access-modal.css new file mode 100644 index 00000000..1e00bc28 --- /dev/null +++ b/static/css/components/modal/example-access-modal.css @@ -0,0 +1,72 @@ +/* Example Access Modal */ +.example-access-modal { + max-width: 550px; + text-align: center; +} + +.example-access-options { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin: var(--space-3) 0; +} + +.example-option-btn { + display: flex; + flex-direction: column; + align-items: center; + padding: var(--space-2); + border-radius: var(--border-radius-sm); + border: 1px solid var(--lora-border); + background-color: var(--lora-surface); + cursor: pointer; + transition: all 0.2s; +} + +.example-option-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + border-color: var(--lora-accent); +} + +.example-option-btn i { + font-size: 2em; + margin-bottom: var(--space-1); + color: var(--lora-accent); +} + +.option-title { + font-weight: 500; + margin-bottom: 4px; + font-size: 1.1em; +} + +.option-desc { + font-size: 0.9em; + opacity: 0.8; +} + +.example-option-btn.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.example-option-btn.disabled i { + color: var(--text-color); + opacity: 0.5; +} + +.modal-footer-note { + font-size: 0.9em; + opacity: 0.7; + margin-top: var(--space-2); + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +/* Dark theme adjustments */ +[data-theme="dark"] .example-option-btn:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} \ No newline at end of file diff --git a/static/css/components/modal/help-modal.css b/static/css/components/modal/help-modal.css new file mode 100644 index 00000000..6e0c4492 --- /dev/null +++ b/static/css/components/modal/help-modal.css @@ -0,0 +1,307 @@ +/* Help Modal styles */ +.help-modal { + max-width: 850px; +} + +.help-header { + display: flex; + align-items: center; + margin-bottom: var(--space-2); +} + +.modal-help-icon { + font-size: 24px; + color: var(--lora-accent); + margin-right: var(--space-2); + vertical-align: text-bottom; +} + +/* Tab navigation styles */ +.help-tabs { + display: flex; + border-bottom: 1px solid var(--lora-border); + margin-bottom: var(--space-2); + gap: 8px; +} + +.tab-btn { + padding: 8px 16px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-color); + cursor: pointer; + font-weight: 500; + transition: all 0.2s; + opacity: 0.7; +} + +.tab-btn:hover { + background-color: rgba(0, 0, 0, 0.05); + opacity: 0.9; +} + +.tab-btn.active { + color: var(--lora-accent); + border-bottom: 2px solid var(--lora-accent); + opacity: 1; +} + +/* Add styles for tab with new content indicator */ +.tab-btn.has-new-content { + position: relative; +} + +.tab-btn.has-new-content::after { + content: ""; + position: absolute; + top: 4px; + right: 4px; + width: 8px; + height: 8px; + background-color: var(--lora-accent); + border-radius: 50%; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.7; transform: scale(1.1); } + 100% { opacity: 1; transform: scale(1); } +} + +/* Tab content styles */ +.help-content { + padding: var(--space-1) 0; + overflow-y: auto; +} + +.tab-pane { + display: none; +} + +.tab-pane.active { + display: block; +} + +.help-text { + margin: var(--space-2) 0; +} + +.help-text ul { + padding-left: 20px; + margin-top: 8px; +} + +.help-text li { + margin-bottom: 8px; +} + +/* Documentation link styles */ +.docs-section { + margin-bottom: var(--space-3); +} + +.docs-section h4 { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: var(--space-1); +} + +.docs-links { + list-style-type: none; + padding-left: var(--space-3); +} + +.docs-links li { + margin-bottom: var(--space-1); + position: relative; +} + +.docs-links li:before { + content: "•"; + position: absolute; + left: -15px; + color: var(--lora-accent); +} + +.docs-links a { + color: var(--lora-accent); + text-decoration: none; + transition: color 0.2s; +} + +.docs-links a:hover { + text-decoration: underline; +} + +/* New content badge styles */ +.new-content-badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.7em; + font-weight: 600; + background-color: var(--lora-accent); + color: var(--lora-text); + padding: 2px 6px; + border-radius: 10px; + margin-left: 8px; + vertical-align: middle; + animation: fadeIn 0.5s ease-in-out; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.new-content-badge.inline { + font-size: 0.65em; + padding: 1px 4px; + margin-left: 6px; + border-radius: 8px; +} + +/* Dark theme adjustments for new content badge */ +[data-theme="dark"] .new-content-badge { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); +} + +/* Update video list styles */ +.video-list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.video-item { + display: flex; + flex-direction: column; +} + +.video-info { + padding: var(--space-1); +} + +.video-info h4 { + margin-bottom: var(--space-1); +} + +.video-info p { + font-size: 0.9em; + opacity: 0.8; +} + +/* Dark theme adjustments */ +[data-theme="dark"] .tab-btn:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +/* Update date badge styles */ +.update-date-badge { + display: inline-flex; + align-items: center; + font-size: 0.75em; + font-weight: 500; + background-color: var(--lora-accent); + color: var(--lora-text); + padding: 4px 8px; + border-radius: 12px; + margin-left: 10px; + vertical-align: middle; + animation: fadeIn 0.5s ease-in-out; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.update-date-badge i { + margin-right: 5px; + font-size: 0.9em; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-5px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Dark theme adjustments */ +[data-theme="dark"] .update-date-badge { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +/* Privacy-friendly video embed styles */ +.video-container { + position: relative; + width: 100%; + padding-bottom: 56.25%; /* 16:9 aspect ratio */ + height: 0; + margin-bottom: var(--space-2); + border-radius: var(--border-radius-sm); + overflow: hidden; + background-color: rgba(0, 0, 0, 0.05); +} + +.video-thumbnail { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +.video-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; + transition: filter 0.2s ease; +} + +.video-play-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + transition: opacity 0.2s ease; +} + +/* External link button styles */ +.external-link-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border-radius: var(--border-radius-sm); + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + background-color: var(--lora-accent); + color: white; + text-decoration: none; + border: none; +} + +.external-link-btn:hover { + background-color: oklch(from var(--lora-accent) l c h / 85%); +} + +.video-thumbnail i { + font-size: 1.2em; +} + +/* Smaller video container for the updates tab */ +.video-item .video-container { + padding-bottom: 40%; /* Shorter height for the playlist */ +} + +/* Dark theme adjustments */ +[data-theme="dark"] .video-container { + background-color: rgba(255, 255, 255, 0.03); +} \ No newline at end of file diff --git a/static/css/components/modal/relink-civitai-modal.css b/static/css/components/modal/relink-civitai-modal.css new file mode 100644 index 00000000..e19515fe --- /dev/null +++ b/static/css/components/modal/relink-civitai-modal.css @@ -0,0 +1,53 @@ +/* Re-link to Civitai Modal styles */ +.warning-box { + background-color: rgba(255, 193, 7, 0.1); + border: 1px solid rgba(255, 193, 7, 0.5); + border-radius: var(--border-radius-sm); + padding: var(--space-2); + margin-bottom: var(--space-3); +} + +.warning-box i { + color: var(--lora-warning); + margin-right: var(--space-1); +} + +.warning-box ul { + padding-left: 20px; + margin: var(--space-1) 0; +} + +.warning-box li { + margin-bottom: 4px; +} + +.input-group { + display: flex; + flex-direction: column; + margin-bottom: var(--space-2); +} + +.input-group label { + margin-bottom: var(--space-1); + font-weight: 500; +} + +.input-group input { + padding: 8px 12px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background-color: var(--lora-surface); + color: var(--text-color); +} + +.input-error { + color: var(--lora-error); + font-size: 0.9em; + min-height: 20px; + margin-top: 4px; +} + +[data-theme="dark"] .warning-box { + background-color: rgba(255, 193, 7, 0.05); + border-color: rgba(255, 193, 7, 0.3); +} \ No newline at end of file diff --git a/static/css/components/modal/settings-modal.css b/static/css/components/modal/settings-modal.css new file mode 100644 index 00000000..7d67e79e --- /dev/null +++ b/static/css/components/modal/settings-modal.css @@ -0,0 +1,485 @@ +/* Settings styles */ +.settings-toggle { + 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.2s ease; +} + +.settings-toggle:hover { + background: var(--lora-accent); + color: white; + transform: translateY(-2px); +} + +.settings-modal { + max-width: 650px; /* Further increased from 600px for more space */ +} + +/* Settings Links */ +.settings-links { + margin-top: var(--space-3); + padding-top: var(--space-2); + border-top: 1px solid var(--lora-border); + display: flex; + gap: var(--space-2); + justify-content: center; +} + +.settings-link { + 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.2s ease; + text-decoration: none; + position: relative; +} + +.settings-link:hover { + background: var(--lora-accent); + color: white; + transform: translateY(-2px); +} + +.settings-link i { + font-size: 1.1em; +} + +/* Tooltip styles */ +.settings-link::after { + content: attr(title); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.8em; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s, visibility 0.2s; + pointer-events: none; +} + +.settings-link:hover::after { + opacity: 1; + visibility: visible; +} + +/* Responsive adjustment */ +@media (max-width: 480px) { + .settings-links { + flex-wrap: wrap; + } +} + +/* API key input specific styles */ +.api-key-input { + width: 100%; /* Take full width of parent */ + position: relative; + display: flex; + align-items: center; +} + +.api-key-input input { + width: 100%; + padding: 6px 40px 6px 10px; /* Add left padding */ + height: 32px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background-color: var(--lora-surface); + color: var(--text-color); +} + +.api-key-input .toggle-visibility { + position: absolute; + right: 8px; + background: none; + border: none; + color: var(--text-color); + opacity: 0.6; + cursor: pointer; + padding: 4px 8px; +} + +.api-key-input .toggle-visibility:hover { + opacity: 1; +} + +.input-help { + font-size: 0.85em; + color: var(--text-color); + opacity: 0.7; + margin-top: 8px; /* Space between control and help */ + line-height: 1.4; + width: 100%; /* Full width */ +} + +/* Settings Styles */ +.settings-section { + margin-top: var(--space-3); + border-top: 1px solid var(--lora-border); + padding-top: var(--space-2); +} + +.settings-section h3 { + font-size: 1.1em; + margin-bottom: var(--space-2); + color: var(--text-color); + opacity: 0.9; +} + +.setting-item { + display: flex; + flex-direction: column; /* Changed to column for help text placement */ + margin-bottom: var(--space-3); /* Increased to provide more spacing between items */ + padding: var(--space-1); + border-radius: var(--border-radius-xs); +} + +.setting-item:hover { + background: rgba(0, 0, 0, 0.02); +} + +[data-theme="dark"] .setting-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +/* Control row with label and input together */ +.setting-row { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.setting-info { + margin-bottom: 0; + width: 35%; /* Increased from 30% to prevent wrapping */ + flex-shrink: 0; /* Prevent shrinking */ +} + +.setting-info label { + display: block; + font-weight: 500; + margin-bottom: 0; + white-space: nowrap; /* Prevent label wrapping */ +} + +.setting-control { + width: 60%; /* Decreased slightly from 65% */ + margin-bottom: 0; + display: flex; + justify-content: flex-end; /* Right-align all controls */ +} + +/* Select Control Styles */ +.select-control { + width: 100%; + display: flex; + justify-content: flex-end; +} + +.select-control select { + width: 100%; + max-width: 100%; /* Increased from 200px */ + padding: 6px 10px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background-color: var(--lora-surface); + color: var(--text-color); + font-size: 0.95em; + height: 32px; +} + +/* Fix dark theme select dropdown text color */ +[data-theme="dark"] .select-control select { + background-color: rgba(30, 30, 30, 0.9); + color: var(--text-color); +} + +[data-theme="dark"] .select-control select option { + background-color: #2d2d2d; + color: var(--text-color); +} + +.select-control select:focus { + border-color: var(--lora-accent); + outline: none; +} + +/* Toggle Switch */ +.toggle-switch { + position: relative; + display: inline-block; + width: 50px; + height: 24px; + cursor: pointer; + margin-left: auto; /* Push to right side */ +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--border-color); + transition: .3s; + border-radius: 24px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + transition: .3s; + border-radius: 50%; +} + +input:checked + .toggle-slider { + background-color: var(--lora-accent); +} + +input:checked + .toggle-slider:before { + transform: translateX(26px); +} + +.toggle-label { + margin-left: 60px; + line-height: 24px; +} + +/* Add small animation for the toggle */ +.toggle-slider:active:before { + width: 22px; +} + +/* Blur effect for NSFW content */ +.nsfw-blur { + filter: blur(12px); + transition: filter 0.3s ease; +} + +.nsfw-blur:hover { + filter: blur(8px); +} + +/* Example Images Settings Styles */ +.download-buttons { + justify-content: flex-start; + gap: var(--space-2); +} + +.path-control { + display: flex; + gap: 8px; + align-items: center; + width: 100%; +} + +.path-control input[type="text"] { + flex: 1; + padding: 6px 10px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background-color: var(--lora-surface); + color: var (--text-color); + font-size: 0.95em; + height: 32px; +} + +/* Add warning text style for settings */ +.warning-text { + color: var(--lora-warning, #e67e22); + font-weight: 500; +} + +[data-theme="dark"] .warning-text { + color: var(--lora-warning, #f39c12); +} + +/* Add styles for list description */ +.list-description { + margin: 8px 0; + padding-left: 20px; + font-size: 0.9em; +} + +.list-description li { + margin-bottom: 4px; +} + +/* Path Template Settings Styles */ +.template-preview { + background: rgba(0, 0, 0, 0.03); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: var(--border-radius-xs); + padding: var(--space-1); + margin-top: 8px; + font-family: monospace; + font-size: 1.1em; + color: var(--lora-accent); + display: none; +} + +[data-theme="dark"] .template-preview { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--lora-border); +} + +.template-preview:before { + content: "Preview: "; + opacity: 0.7; + color: var(--text-color); + font-family: inherit; +} + +/* Base Model Mappings Styles - Updated to match other settings */ +.mappings-container { + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + padding: var(--space-2); + background: rgba(0, 0, 0, 0.02); + margin-top: 8px; /* Add consistent spacing */ +} + +[data-theme="dark"] .mappings-container { + background: rgba(255, 255, 255, 0.02); +} + +.add-mapping-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--lora-accent); + color: white; + border: none; + border-radius: var(--border-radius-xs); + cursor: pointer; + font-size: 0.9em; + transition: all 0.2s; + height: 32px; /* Match other control heights */ +} + +.add-mapping-btn:hover { + background: oklch(from var(--lora-accent) l c h / 85%); +} + +.mapping-row { + margin-bottom: var(--space-2); +} + +.mapping-row:last-child { + margin-bottom: 0; +} + +.mapping-controls { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: var(--space-1); + align-items: center; +} + +.base-model-select, +.path-value-input { + padding: 6px 10px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background-color: var(--lora-surface); + color: var(--text-color); + font-size: 0.9em; + height: 32px; +} + +.path-value-input { + height: 18px; +} + +.base-model-select:focus, +.path-value-input:focus { + border-color: var(--lora-accent); + outline: none; + box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1); +} + +.remove-mapping-btn { + width: 32px; + height: 32px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--lora-error); + background: transparent; + color: var(--lora-error); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.remove-mapping-btn:hover { + background: var(--lora-error); + color: white; +} + +.mapping-empty-state { + text-align: center; + padding: var(--space-3); + color: var(--text-color); + opacity: 0.6; + font-style: italic; +} + +/* Responsive adjustments for mapping controls */ +@media (max-width: 768px) { + .mapping-controls { + grid-template-columns: 1fr; + gap: 8px; + } + + .remove-mapping-btn { + width: 100%; + height: 36px; + justify-self: stretch; + } +} + +/* Dark theme specific adjustments */ +[data-theme="dark"] .base-model-select, +[data-theme="dark"] .path-value-input { + background-color: rgba(30, 30, 30, 0.9); +} + +[data-theme="dark"] .base-model-select option { + background-color: #2d2d2d; + color: var(--text-color); +} \ No newline at end of file diff --git a/static/css/components/support-modal.css b/static/css/components/modal/support-modal.css similarity index 100% rename from static/css/components/support-modal.css rename to static/css/components/modal/support-modal.css diff --git a/static/css/components/modal/update-modal.css b/static/css/components/modal/update-modal.css new file mode 100644 index 00000000..7645b751 --- /dev/null +++ b/static/css/components/modal/update-modal.css @@ -0,0 +1,124 @@ +/* Update Modal specific styles */ +.update-actions { + display: flex; + flex-direction: column; + gap: var(--space-2); + align-items: stretch; + flex-wrap: nowrap; +} + +.update-link { + color: var(--lora-accent); + text-decoration: none; + display: flex; + align-items: center; + gap: 8px; + font-size: 0.95em; +} + +.update-link:hover { + text-decoration: underline; +} + +/* Update progress styles */ +.update-progress { + background: rgba(0, 0, 0, 0.03); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + padding: var(--space-2); + margin: var(--space-2) 0; +} + +[data-theme="dark"] .update-progress { + background: rgba(255, 255, 255, 0.03); +} + +.progress-info { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.progress-text { + font-size: 0.9em; + color: var(--text-color); + opacity: 0.8; +} + +.progress-bar { + width: 100%; + height: 8px; + background-color: rgba(0, 0, 0, 0.1); + border-radius: 4px; + overflow: hidden; +} + +[data-theme="dark"] .progress-bar { + background-color: rgba(255, 255, 255, 0.1); +} + +.progress-fill { + height: 100%; + background-color: var(--lora-accent); + width: 0%; + transition: width 0.3s ease; + border-radius: 4px; +} + +/* Update button states */ +#updateBtn { + min-width: 120px; +} + +#updateBtn.updating { + background-color: var(--lora-warning); + cursor: not-allowed; +} + +#updateBtn.success { + background-color: var(--lora-success); +} + +#updateBtn.error { + background-color: var(--lora-error); +} + +/* Add styles for markdown elements in changelog */ +.changelog-item ul { + padding-left: 20px; + margin-top: 8px; +} + +.changelog-item li { + margin-bottom: 6px; + line-height: 1.4; +} + +.changelog-item strong { + font-weight: 600; +} + +.changelog-item em { + font-style: italic; +} + +.changelog-item code { + background: rgba(0, 0, 0, 0.05); + padding: 2px 4px; + border-radius: 3px; + font-family: monospace; + font-size: 0.9em; +} + +[data-theme="dark"] .changelog-item code { + background: rgba(255, 255, 255, 0.1); +} + +.changelog-item a { + color: var(--lora-accent); + text-decoration: none; +} + +.changelog-item a:hover { + text-decoration: underline; +} \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index c2d18a54..91644220 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -7,7 +7,14 @@ /* Import Components */ @import 'components/header.css'; @import 'components/card.css'; -@import 'components/modal.css'; +@import 'components/modal/_base.css'; +@import 'components/modal/delete-modal.css'; +@import 'components/modal/update-modal.css'; +@import 'components/modal/settings-modal.css'; +@import 'components/modal/help-modal.css'; +@import 'components/modal/relink-civitai-modal.css'; +@import 'components/modal/example-access-modal.css'; +@import 'components/modal/support-modal.css'; @import 'components/download-modal.css'; @import 'components/toast.css'; @import 'components/loading.css'; @@ -20,7 +27,6 @@ @import 'components/lora-modal/showcase.css'; @import 'components/lora-modal/triggerwords.css'; @import 'components/shared/edit-metadata.css'; -@import 'components/support-modal.css'; @import 'components/search-filter.css'; @import 'components/bulk.css'; @import 'components/shared.css'; From 7f205cdcc828aadcaca6daba2d06ce243bbda932 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Fri, 25 Jul 2025 17:35:06 +0800 Subject: [PATCH 09/20] refactor: unify model download system across all model types - Add download-related methods to baseModelApi.js for fetching versions, roots, folders, and downloading models - Replace separate download managers with a unified DownloadManager.js supporting all model types - Create a single download_modals.html template that adapts to model type (LoRA, checkpoint, etc.) - Remove old download modals from lora_modals.html and checkpoint_modals.html - Update apiConfig.js to include civitaiVersions endpoints for each model type - Centralize event handler binding in DownloadManager.js (no more inline HTML handlers) - Modal UI and logic now auto-adapt to the current model type, making future extension easier --- static/js/api/apiConfig.js | 5 +- static/js/api/baseModelApi.js | 80 +++ static/js/checkpoints.js | 4 - .../ContextMenu/CheckpointContextMenu.js | 7 +- .../controls/CheckpointsControls.js | 7 +- .../js/components/controls/LorasControls.js | 7 +- static/js/loras.js | 5 - .../js/managers/CheckpointDownloadManager.js | 463 ------------------ static/js/managers/DownloadManager.js | 259 +++++----- templates/checkpoints.html | 1 - templates/components/checkpoint_modals.html | 73 --- templates/components/lora_modals.html | 102 ---- templates/components/modals.html | 7 +- .../components/modals/download_modal.html | 62 +++ templates/components/modals/move_modal.html | 36 ++ templates/loras.html | 4 - 16 files changed, 337 insertions(+), 785 deletions(-) delete mode 100644 static/js/managers/CheckpointDownloadManager.js delete mode 100644 templates/components/checkpoint_modals.html delete mode 100644 templates/components/lora_modals.html create mode 100644 templates/components/modals/download_modal.html create mode 100644 templates/components/modals/move_modal.html diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index 6f02033f..53b3f842 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -103,13 +103,12 @@ export const MODEL_SPECIFIC_ENDPOINTS = { moveBulk: `/api/${MODEL_TYPES.LORA}/move_models_bulk`, getTriggerWordsPost: `/api/${MODEL_TYPES.LORA}/get_trigger_words`, civitaiModelByVersion: `/api/${MODEL_TYPES.LORA}/civitai/model/version`, - civitaiModelByHash: `/api/${MODEL_TYPES.LORA}/civitai/model/hash` + civitaiModelByHash: `/api/${MODEL_TYPES.LORA}/civitai/model/hash`, }, [MODEL_TYPES.CHECKPOINT]: { - info: `/api/${MODEL_TYPES.CHECKPOINT}/info` + info: `/api/${MODEL_TYPES.CHECKPOINT}/info`, }, [MODEL_TYPES.EMBEDDING]: { - // Future embedding-specific endpoints } }; diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 692a83db..042871cd 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -581,6 +581,86 @@ class ModelApiClient { return successFilePaths; } + /** + * Fetch Civitai model versions + */ + async fetchCivitaiVersions(modelId) { + try { + const response = await fetch(`${this.apiConfig.endpoints.civitaiVersions}/${modelId}`); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + if (errorData && errorData.error && errorData.error.includes('Model type mismatch')) { + throw new Error(`This model is not a ${this.apiConfig.config.displayName}. Please switch to the appropriate page to download this model type.`); + } + throw new Error('Failed to fetch model versions'); + } + return await response.json(); + } catch (error) { + console.error('Error fetching Civitai versions:', error); + throw error; + } + } + + /** + * Fetch model roots + */ + async fetchModelRoots() { + try { + const response = await fetch(this.apiConfig.endpoints.roots); + if (!response.ok) { + throw new Error(`Failed to fetch ${this.apiConfig.config.displayName} roots`); + } + return await response.json(); + } catch (error) { + console.error('Error fetching model roots:', error); + throw error; + } + } + + /** + * Fetch model folders + */ + async fetchModelFolders() { + try { + const response = await fetch(this.apiConfig.endpoints.folders); + if (!response.ok) { + throw new Error(`Failed to fetch ${this.apiConfig.config.displayName} folders`); + } + return await response.json(); + } catch (error) { + console.error('Error fetching model folders:', error); + throw error; + } + } + + /** + * Download a model + */ + async downloadModel(modelId, versionId, modelRoot, relativePath, downloadId) { + try { + const response = await fetch(DOWNLOAD_ENDPOINTS.download, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model_id: modelId, + model_version_id: versionId, + model_root: modelRoot, + relative_path: relativePath, + download_id: downloadId + }) + }); + + if (!response.ok) { + throw new Error(await response.text()); + } + + return await response.json(); + } catch (error) { + console.error('Error downloading model:', error); + throw error; + } + } + /** * Build query parameters for API requests */ diff --git a/static/js/checkpoints.js b/static/js/checkpoints.js index 69ab6224..cbc02ace 100644 --- a/static/js/checkpoints.js +++ b/static/js/checkpoints.js @@ -2,7 +2,6 @@ import { appCore } from './core.js'; import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js'; import { createPageControls } from './components/controls/index.js'; import { loadMoreCheckpoints } from './api/checkpointApi.js'; -import { CheckpointDownloadManager } from './managers/CheckpointDownloadManager.js'; import { CheckpointContextMenu } from './components/ContextMenu/index.js'; import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; import { MODEL_TYPES } from './api/apiConfig.js'; @@ -13,9 +12,6 @@ class CheckpointsPageManager { // Initialize page controls this.pageControls = createPageControls(MODEL_TYPES.CHECKPOINT); - // Initialize checkpoint download manager - window.checkpointDownloadManager = new CheckpointDownloadManager(); - // Initialize the ModelDuplicatesManager this.duplicatesManager = new ModelDuplicatesManager(this, MODEL_TYPES.CHECKPOINT); diff --git a/static/js/components/ContextMenu/CheckpointContextMenu.js b/static/js/components/ContextMenu/CheckpointContextMenu.js index 8e589b51..b7be4b9f 100644 --- a/static/js/components/ContextMenu/CheckpointContextMenu.js +++ b/static/js/components/ContextMenu/CheckpointContextMenu.js @@ -3,7 +3,7 @@ import { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; import { resetAndReload } from '../../api/checkpointApi.js'; import { getModelApiClient } from '../../api/baseModelApi.js'; import { showToast } from '../../utils/uiHelpers.js'; -import { showExcludeModal } from '../../utils/modalUtils.js'; +import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js'; export class CheckpointContextMenu extends BaseContextMenu { constructor() { @@ -42,10 +42,7 @@ export class CheckpointContextMenu extends BaseContextMenu { apiClient.replaceModelPreview(this.currentCard.dataset.filepath); break; case 'delete': - // Delete checkpoint - if (this.currentCard.querySelector('.fa-trash')) { - this.currentCard.querySelector('.fa-trash').click(); - } + showDeleteModal(this.currentCard.dataset.filepath); break; case 'copyname': // Copy checkpoint name diff --git a/static/js/components/controls/CheckpointsControls.js b/static/js/components/controls/CheckpointsControls.js index f5af38a8..6528a8a5 100644 --- a/static/js/components/controls/CheckpointsControls.js +++ b/static/js/components/controls/CheckpointsControls.js @@ -2,7 +2,7 @@ import { PageControls } from './PageControls.js'; import { loadMoreCheckpoints, resetAndReload, refreshCheckpoints, fetchCivitai } from '../../api/checkpointApi.js'; import { showToast } from '../../utils/uiHelpers.js'; -import { CheckpointDownloadManager } from '../../managers/CheckpointDownloadManager.js'; +import { downloadManager } from '../../managers/DownloadManager.js'; /** * CheckpointsControls class - Extends PageControls for Checkpoint-specific functionality @@ -12,9 +12,6 @@ export class CheckpointsControls extends PageControls { // Initialize with 'checkpoints' page type super('checkpoints'); - // Initialize checkpoint download manager - this.downloadManager = new CheckpointDownloadManager(); - // Register API methods specific to the Checkpoints page this.registerCheckpointsAPI(); } @@ -44,7 +41,7 @@ export class CheckpointsControls extends PageControls { // Add show download modal functionality showDownloadModal: () => { - this.downloadManager.showDownloadModal(); + downloadManager.showDownloadModal(); }, // No clearCustomFilter implementation is needed for checkpoints diff --git a/static/js/components/controls/LorasControls.js b/static/js/components/controls/LorasControls.js index 22f768d4..dee5e128 100644 --- a/static/js/components/controls/LorasControls.js +++ b/static/js/components/controls/LorasControls.js @@ -3,6 +3,7 @@ import { PageControls } from './PageControls.js'; import { loadMoreLoras, fetchCivitai, resetAndReload, refreshLoras } from '../../api/loraApi.js'; import { getSessionItem, removeSessionItem } from '../../utils/storageHelpers.js'; import { createAlphabetBar } from '../alphabet/index.js'; +import { downloadManager } from '../../managers/DownloadManager.js'; /** * LorasControls class - Extends PageControls for LoRA-specific functionality @@ -46,11 +47,7 @@ export class LorasControls extends PageControls { }, showDownloadModal: () => { - if (window.downloadManager) { - window.downloadManager.showDownloadModal(); - } else { - console.error('Download manager not available'); - } + downloadManager.showDownloadModal(); }, toggleBulkMode: () => { diff --git a/static/js/loras.js b/static/js/loras.js index c5e2f77a..537ed628 100644 --- a/static/js/loras.js +++ b/static/js/loras.js @@ -3,7 +3,6 @@ import { state } from './state/index.js'; import { loadMoreLoras } from './api/loraApi.js'; import { updateCardsForBulkMode } from './components/LoraCard.js'; import { bulkManager } from './managers/BulkManager.js'; -import { DownloadManager } from './managers/DownloadManager.js'; import { moveManager } from './managers/MoveManager.js'; import { LoraContextMenu } from './components/ContextMenu/index.js'; import { createPageControls } from './components/controls/index.js'; @@ -17,9 +16,6 @@ class LoraPageManager { state.bulkMode = false; state.selectedLoras = new Set(); - // Initialize managers - this.downloadManager = new DownloadManager(); - // Initialize page controls this.pageControls = createPageControls('loras'); @@ -39,7 +35,6 @@ class LoraPageManager { window.closeDeleteModal = closeDeleteModal; window.confirmExclude = confirmExclude; window.closeExcludeModal = closeExcludeModal; - window.downloadManager = this.downloadManager; window.moveManager = moveManager; // Bulk operations diff --git a/static/js/managers/CheckpointDownloadManager.js b/static/js/managers/CheckpointDownloadManager.js deleted file mode 100644 index 7545e617..00000000 --- a/static/js/managers/CheckpointDownloadManager.js +++ /dev/null @@ -1,463 +0,0 @@ -import { modalManager } from './ModalManager.js'; -import { showToast } from '../utils/uiHelpers.js'; -import { LoadingManager } from './LoadingManager.js'; -import { state } from '../state/index.js'; -import { resetAndReload } from '../api/checkpointApi.js'; -import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; - -export class CheckpointDownloadManager { - constructor() { - this.currentVersion = null; - this.versions = []; - this.modelInfo = null; - this.modelVersionId = null; - - this.initialized = false; - this.selectedFolder = ''; - - this.loadingManager = new LoadingManager(); - this.folderClickHandler = null; - this.updateTargetPath = this.updateTargetPath.bind(this); - } - - showDownloadModal() { - console.log('Showing checkpoint download modal...'); - if (!this.initialized) { - const modal = document.getElementById('checkpointDownloadModal'); - if (!modal) { - console.error('Checkpoint download modal element not found'); - return; - } - this.initialized = true; - } - - modalManager.showModal('checkpointDownloadModal', null, () => { - // Cleanup handler when modal closes - this.cleanupFolderBrowser(); - }); - this.resetSteps(); - - // Auto-focus on the URL input - setTimeout(() => { - const urlInput = document.getElementById('checkpointUrl'); - if (urlInput) { - urlInput.focus(); - } - }, 100); // Small delay to ensure the modal is fully displayed - } - - resetSteps() { - document.querySelectorAll('#checkpointDownloadModal .download-step').forEach(step => step.style.display = 'none'); - document.getElementById('cpUrlStep').style.display = 'block'; - document.getElementById('checkpointUrl').value = ''; - document.getElementById('cpUrlError').textContent = ''; - - // Clear new folder input - const newFolderInput = document.getElementById('cpNewFolder'); - if (newFolderInput) { - newFolderInput.value = ''; - } - - this.currentVersion = null; - this.versions = []; - this.modelInfo = null; - this.modelId = null; - this.modelVersionId = null; - - // Clear selected folder and remove selection from UI - this.selectedFolder = ''; - const folderBrowser = document.getElementById('cpFolderBrowser'); - if (folderBrowser) { - folderBrowser.querySelectorAll('.folder-item').forEach(f => - f.classList.remove('selected')); - } - } - - async validateAndFetchVersions() { - const url = document.getElementById('checkpointUrl').value.trim(); - const errorElement = document.getElementById('cpUrlError'); - - try { - this.loadingManager.showSimpleLoading('Fetching model versions...'); - - this.modelId = this.extractModelId(url); - if (!this.modelId) { - throw new Error('Invalid Civitai URL format'); - } - - const response = await fetch(`/api/checkpoints/civitai/versions/${this.modelId}`); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - if (errorData && errorData.error && errorData.error.includes('Model type mismatch')) { - throw new Error('This model is not a Checkpoint. Please switch to the LoRAs page to download LoRA models.'); - } - throw new Error('Failed to fetch model versions'); - } - - this.versions = await response.json(); - if (!this.versions.length) { - throw new Error('No versions available for this model'); - } - - // If we have a version ID from URL, pre-select it - if (this.modelVersionId) { - this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId); - } - - this.showVersionStep(); - } catch (error) { - errorElement.textContent = error.message; - } finally { - this.loadingManager.hide(); - } - } - - extractModelId(url) { - const modelMatch = url.match(/civitai\.com\/models\/(\d+)/); - const versionMatch = url.match(/modelVersionId=(\d+)/); - - if (modelMatch) { - this.modelVersionId = versionMatch ? versionMatch[1] : null; - return modelMatch[1]; - } - return null; - } - - showVersionStep() { - document.getElementById('cpUrlStep').style.display = 'none'; - document.getElementById('cpVersionStep').style.display = 'block'; - - const versionList = document.getElementById('cpVersionList'); - versionList.innerHTML = this.versions.map(version => { - const firstImage = version.images?.find(img => !img.url.endsWith('.mp4')); - const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png'; - - // Use version-level size or fallback to first file - const fileSize = version.modelSizeKB ? - (version.modelSizeKB / 1024).toFixed(2) : - (version.files[0]?.sizeKB / 1024).toFixed(2); - - // Use version-level existsLocally flag - const existsLocally = version.existsLocally; - const localPath = version.localPath; - - // Check if this is an early access version - const isEarlyAccess = version.availability === 'EarlyAccess'; - - // Create early access badge if needed - let earlyAccessBadge = ''; - if (isEarlyAccess) { - earlyAccessBadge = ` -
- Early Access -
- `; - } - - // Status badge for local models - const localStatus = existsLocally ? - `
- In Library -
${localPath || ''}
-
` : ''; - - return ` -
-
- Version preview -
-
-
-

${version.name}

- ${localStatus} -
-
- ${version.baseModel ? `
${version.baseModel}
` : ''} - ${earlyAccessBadge} -
-
- ${new Date(version.createdAt).toLocaleDateString()} - ${fileSize} MB -
-
-
- `; - }).join(''); - - // Auto-select the version if there's only one - if (this.versions.length === 1 && !this.currentVersion) { - this.selectVersion(this.versions[0].id.toString()); - } - - // Update Next button state based on initial selection - this.updateNextButtonState(); - } - - selectVersion(versionId) { - this.currentVersion = this.versions.find(v => v.id.toString() === versionId.toString()); - if (!this.currentVersion) return; - - document.querySelectorAll('#cpVersionList .version-item').forEach(item => { - item.classList.toggle('selected', item.querySelector('h3').textContent === this.currentVersion.name); - }); - - // Update Next button state after selection - this.updateNextButtonState(); - } - - updateNextButtonState() { - const nextButton = document.querySelector('#cpVersionStep .primary-btn'); - if (!nextButton) return; - - const existsLocally = this.currentVersion?.existsLocally; - - if (existsLocally) { - nextButton.disabled = true; - nextButton.classList.add('disabled'); - nextButton.textContent = 'Already in Library'; - } else { - nextButton.disabled = false; - nextButton.classList.remove('disabled'); - nextButton.textContent = 'Next'; - } - } - - async proceedToLocation() { - if (!this.currentVersion) { - showToast('Please select a version', 'error'); - return; - } - - // Double-check if the version exists locally - const existsLocally = this.currentVersion.existsLocally; - if (existsLocally) { - showToast('This version already exists in your library', 'info'); - return; - } - - document.getElementById('cpVersionStep').style.display = 'none'; - document.getElementById('cpLocationStep').style.display = 'block'; - - try { - // Use checkpoint roots endpoint instead of lora roots - const response = await fetch('/api/checkpoints/roots'); - if (!response.ok) { - throw new Error('Failed to fetch checkpoint roots'); - } - - const data = await response.json(); - const checkpointRoot = document.getElementById('checkpointRoot'); - checkpointRoot.innerHTML = data.roots.map(root => - `` - ).join(''); - - // Set default checkpoint root if available - const defaultRoot = getStorageItem('settings', {}).default_checkpoint_root; - if (defaultRoot && data.roots.includes(defaultRoot)) { - checkpointRoot.value = defaultRoot; - } - - // Initialize folder browser after loading roots - this.initializeFolderBrowser(); - } catch (error) { - showToast(error.message, 'error'); - } - } - - backToUrl() { - document.getElementById('cpVersionStep').style.display = 'none'; - document.getElementById('cpUrlStep').style.display = 'block'; - } - - backToVersions() { - document.getElementById('cpLocationStep').style.display = 'none'; - document.getElementById('cpVersionStep').style.display = 'block'; - } - - async startDownload() { - const checkpointRoot = document.getElementById('checkpointRoot').value; - const newFolder = document.getElementById('cpNewFolder').value.trim(); - - if (!checkpointRoot) { - showToast('Please select a checkpoint root directory', 'error'); - return; - } - - // Construct relative path - let targetFolder = ''; - if (this.selectedFolder) { - targetFolder = this.selectedFolder; - } - if (newFolder) { - targetFolder = targetFolder ? - `${targetFolder}/${newFolder}` : newFolder; - } - - try { - // Show enhanced loading with progress details - const updateProgress = this.loadingManager.showDownloadProgress(1); - updateProgress(0, 0, this.currentVersion.name); - - // Generate a unique ID for this download - const downloadId = Date.now().toString(); - - // Setup WebSocket for progress updates using download-specific endpoint - const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; - const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`); - - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - - // Handle download ID confirmation - if (data.type === 'download_id') { - console.log(`Connected to checkpoint download progress with ID: ${data.download_id}`); - return; - } - - // Only process progress updates for our download - if (data.status === 'progress' && data.download_id === downloadId) { - // Update progress display with current progress - updateProgress(data.progress, 0, this.currentVersion.name); - - // Add more detailed status messages based on progress - if (data.progress < 3) { - this.loadingManager.setStatus(`Preparing download...`); - } else if (data.progress === 3) { - this.loadingManager.setStatus(`Downloaded preview image`); - } else if (data.progress > 3 && data.progress < 100) { - this.loadingManager.setStatus(`Downloading checkpoint file`); - } else { - this.loadingManager.setStatus(`Finalizing download...`); - } - } - }; - - ws.onerror = (error) => { - console.error('WebSocket error:', error); - // Continue with download even if WebSocket fails - }; - - // Start download using checkpoint download endpoint with download ID - const response = await fetch('/api/download-model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model_id: this.modelId, - model_version_id: this.currentVersion.id, - model_root: checkpointRoot, - relative_path: targetFolder, - download_id: downloadId - }) - }); - - if (!response.ok) { - throw new Error(await response.text()); - } - - showToast('Download completed successfully', 'success'); - modalManager.closeModal('checkpointDownloadModal'); - - // Update state specifically for the checkpoints page - state.pages.checkpoints.activeFolder = targetFolder; - - // Save the active folder preference to storage - setStorageItem('checkpoints_activeFolder', targetFolder); - - // Update UI to show the folder as selected - document.querySelectorAll('.folder-tags .tag').forEach(tag => { - const isActive = tag.dataset.folder === targetFolder; - tag.classList.toggle('active', isActive); - if (isActive && !tag.parentNode.classList.contains('collapsed')) { - // Scroll the tag into view if folder tags are not collapsed - tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } - }); - - await resetAndReload(true); // Pass true to update folders - - } catch (error) { - showToast(error.message, 'error'); - } finally { - this.loadingManager.hide(); - } - } - - initializeFolderBrowser() { - const folderBrowser = document.getElementById('cpFolderBrowser'); - if (!folderBrowser) return; - - // Cleanup existing handler if any - this.cleanupFolderBrowser(); - - // Create new handler - this.folderClickHandler = (event) => { - const folderItem = event.target.closest('.folder-item'); - if (!folderItem) return; - - if (folderItem.classList.contains('selected')) { - folderItem.classList.remove('selected'); - this.selectedFolder = ''; - } else { - folderBrowser.querySelectorAll('.folder-item').forEach(f => - f.classList.remove('selected')); - folderItem.classList.add('selected'); - this.selectedFolder = folderItem.dataset.folder; - } - - // Update path display after folder selection - this.updateTargetPath(); - }; - - // Add the new handler - folderBrowser.addEventListener('click', this.folderClickHandler); - - // Add event listeners for path updates - const checkpointRoot = document.getElementById('checkpointRoot'); - const newFolder = document.getElementById('cpNewFolder'); - - checkpointRoot.addEventListener('change', this.updateTargetPath); - newFolder.addEventListener('input', this.updateTargetPath); - - // Update initial path - this.updateTargetPath(); - } - - cleanupFolderBrowser() { - if (this.folderClickHandler) { - const folderBrowser = document.getElementById('cpFolderBrowser'); - if (folderBrowser) { - folderBrowser.removeEventListener('click', this.folderClickHandler); - this.folderClickHandler = null; - } - } - - // Remove path update listeners - const checkpointRoot = document.getElementById('checkpointRoot'); - const newFolder = document.getElementById('cpNewFolder'); - - if (checkpointRoot) checkpointRoot.removeEventListener('change', this.updateTargetPath); - if (newFolder) newFolder.removeEventListener('input', this.updateTargetPath); - } - - updateTargetPath() { - const pathDisplay = document.getElementById('cpTargetPathDisplay'); - const checkpointRoot = document.getElementById('checkpointRoot').value; - const newFolder = document.getElementById('cpNewFolder').value.trim(); - - let fullPath = checkpointRoot || 'Select a checkpoint root directory'; - - if (checkpointRoot) { - if (this.selectedFolder) { - fullPath += '/' + this.selectedFolder; - } - if (newFolder) { - fullPath += '/' + newFolder; - } - } - - pathDisplay.innerHTML = `${fullPath}`; - } -} \ No newline at end of file diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index f4f5b9ba..d5c6ab29 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -1,60 +1,111 @@ import { modalManager } from './ModalManager.js'; import { showToast } from '../utils/uiHelpers.js'; import { LoadingManager } from './LoadingManager.js'; -import { state } from '../state/index.js'; -import { resetAndReload } from '../api/loraApi.js'; -import { getStorageItem } from '../utils/storageHelpers.js'; +import { getModelApiClient } from '../api/baseModelApi.js'; +import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; + export class DownloadManager { constructor() { this.currentVersion = null; this.versions = []; this.modelInfo = null; - this.modelVersionId = null; // Add new property for initial version ID + this.modelVersionId = null; + this.modelId = null; - // Add initialization check this.initialized = false; this.selectedFolder = ''; + this.apiClient = null; - // Add LoadingManager instance this.loadingManager = new LoadingManager(); - this.folderClickHandler = null; // Add this line + this.folderClickHandler = null; this.updateTargetPath = this.updateTargetPath.bind(this); + + // Bound methods for event handling + this.handleValidateAndFetchVersions = this.validateAndFetchVersions.bind(this); + this.handleProceedToLocation = this.proceedToLocation.bind(this); + this.handleStartDownload = this.startDownload.bind(this); + this.handleBackToUrl = this.backToUrl.bind(this); + this.handleBackToVersions = this.backToVersions.bind(this); + this.handleCloseModal = this.closeModal.bind(this); } showDownloadModal() { - console.log('Showing download modal...'); // Add debug log + console.log('Showing unified download modal...'); + + // Get API client for current page type + this.apiClient = getModelApiClient(); + const config = this.apiClient.apiConfig.config; + if (!this.initialized) { - // Check if modal exists const modal = document.getElementById('downloadModal'); if (!modal) { - console.error('Download modal element not found'); + console.error('Unified download modal element not found'); return; } + this.initializeEventHandlers(); this.initialized = true; } + // Update modal title and labels based on model type + this.updateModalLabels(); + modalManager.showModal('downloadModal', null, () => { - // Cleanup handler when modal closes this.cleanupFolderBrowser(); }); this.resetSteps(); // Auto-focus on the URL input setTimeout(() => { - const urlInput = document.getElementById('loraUrl'); + const urlInput = document.getElementById('modelUrl'); if (urlInput) { urlInput.focus(); } - }, 100); // Small delay to ensure the modal is fully displayed + }, 100); + } + + initializeEventHandlers() { + // Button event handlers + document.getElementById('nextFromUrl').addEventListener('click', this.handleValidateAndFetchVersions); + document.getElementById('nextFromVersion').addEventListener('click', this.handleProceedToLocation); + document.getElementById('startDownloadBtn').addEventListener('click', this.handleStartDownload); + document.getElementById('backToUrlBtn').addEventListener('click', this.handleBackToUrl); + document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions); + document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal); + } + + updateModalLabels() { + const config = this.apiClient.apiConfig.config; + + // Update modal title + document.getElementById('downloadModalTitle').textContent = `Download ${config.displayName} from URL`; + + // Update URL label + document.getElementById('modelUrlLabel').textContent = 'Civitai URL:'; + + // Update root selection label + document.getElementById('modelRootLabel').textContent = `Select ${config.displayName} Root:`; + + // 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:'; + } + }); + + // Update initial path text + const pathText = document.querySelector('#targetPathDisplay .path-text'); + if (pathText) { + pathText.textContent = `Select a ${config.displayName} root directory`; + } } resetSteps() { document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none'); document.getElementById('urlStep').style.display = 'block'; - document.getElementById('loraUrl').value = ''; + document.getElementById('modelUrl').value = ''; document.getElementById('urlError').textContent = ''; - // Clear new folder input const newFolderInput = document.getElementById('newFolder'); if (newFolderInput) { newFolderInput.value = ''; @@ -66,7 +117,6 @@ export class DownloadManager { this.modelId = null; this.modelVersionId = null; - // Clear selected folder and remove selection from UI this.selectedFolder = ''; const folderBrowser = document.getElementById('folderBrowser'); if (folderBrowser) { @@ -76,7 +126,7 @@ export class DownloadManager { } async validateAndFetchVersions() { - const url = document.getElementById('loraUrl').value.trim(); + const url = document.getElementById('modelUrl').value.trim(); const errorElement = document.getElementById('urlError'); try { @@ -87,16 +137,8 @@ export class DownloadManager { throw new Error('Invalid Civitai URL format'); } - const response = await fetch(`/api/loras/civitai/versions/${this.modelId}`); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - if (errorData && errorData.error && errorData.error.includes('Model type mismatch')) { - throw new Error('This model is not a LoRA. Please switch to the Checkpoints page to download checkpoint models.'); - } - throw new Error('Failed to fetch model versions'); - } + this.versions = await this.apiClient.fetchCivitaiVersions(this.modelId); - this.versions = await response.json(); if (!this.versions.length) { throw new Error('No versions available for this model'); } @@ -134,19 +176,14 @@ export class DownloadManager { const firstImage = version.images?.find(img => !img.url.endsWith('.mp4')); const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png'; - // Use version-level size or fallback to first file const fileSize = version.modelSizeKB ? (version.modelSizeKB / 1024).toFixed(2) : (version.files[0]?.sizeKB / 1024).toFixed(2); - // Use version-level existsLocally flag const existsLocally = version.existsLocally; const localPath = version.localPath; - - // Check if this is an early access version const isEarlyAccess = version.availability === 'EarlyAccess'; - // Create early access badge if needed let earlyAccessBadge = ''; if (isEarlyAccess) { earlyAccessBadge = ` @@ -156,7 +193,6 @@ export class DownloadManager { `; } - // Status badge for local models const localStatus = existsLocally ? `
In Library @@ -167,7 +203,7 @@ export class DownloadManager {
+ data-version-id="${version.id}">
Version preview
@@ -189,12 +225,19 @@ export class DownloadManager { `; }).join(''); + // Add click handlers for version selection + versionList.addEventListener('click', (event) => { + const versionItem = event.target.closest('.version-item'); + if (versionItem) { + this.selectVersion(versionItem.dataset.versionId); + } + }); + // Auto-select the version if there's only one if (this.versions.length === 1 && !this.currentVersion) { this.selectVersion(this.versions[0].id.toString()); } - // Update Next button state based on initial selection this.updateNextButtonState(); } @@ -202,23 +245,15 @@ export class DownloadManager { this.currentVersion = this.versions.find(v => v.id.toString() === versionId.toString()); if (!this.currentVersion) return; - // Remove the toast notification - it's redundant with the visual indicator - // const existsLocally = this.currentVersion.files[0]?.existsLocally; - // if (existsLocally) { - // showToast('This version already exists in your library', 'info'); - // } - document.querySelectorAll('.version-item').forEach(item => { - item.classList.toggle('selected', item.querySelector('h3').textContent === this.currentVersion.name); + item.classList.toggle('selected', item.dataset.versionId === versionId); }); - // Update Next button state after selection this.updateNextButtonState(); } - // Update this method to use version-level existsLocally updateNextButtonState() { - const nextButton = document.querySelector('#versionStep .primary-btn'); + const nextButton = document.getElementById('nextFromVersion'); if (!nextButton) return; const existsLocally = this.currentVersion?.existsLocally; @@ -240,7 +275,6 @@ export class DownloadManager { return; } - // Double-check if the version exists locally const existsLocally = this.currentVersion.existsLocally; if (existsLocally) { showToast('This version already exists in your library', 'info'); @@ -251,39 +285,30 @@ export class DownloadManager { document.getElementById('locationStep').style.display = 'block'; try { - // Fetch LoRA roots - const rootsResponse = await fetch('/api/loras/roots'); - if (!rootsResponse.ok) { - throw new Error('Failed to fetch LoRA roots'); - } + const config = this.apiClient.apiConfig.config; - const rootsData = await rootsResponse.json(); - const loraRoot = document.getElementById('loraRoot'); - loraRoot.innerHTML = rootsData.roots.map(root => + // Fetch model roots + const rootsData = await this.apiClient.fetchModelRoots(); + const modelRoot = document.getElementById('modelRoot'); + modelRoot.innerHTML = rootsData.roots.map(root => `` ).join(''); - // Set default lora root if available - const defaultRoot = getStorageItem('settings', {}).default_loras_root; + // Set default root if available + const defaultRootKey = `default_${this.apiClient.modelType}_root`; + const defaultRoot = getStorageItem('settings', {})[defaultRootKey]; if (defaultRoot && rootsData.roots.includes(defaultRoot)) { - loraRoot.value = defaultRoot; + modelRoot.value = defaultRoot; } - // Fetch folders dynamically - const foldersResponse = await fetch('/api/loras/folders'); - if (!foldersResponse.ok) { - throw new Error('Failed to fetch folders'); - } - - const foldersData = await foldersResponse.json(); + // Fetch folders + const foldersData = await this.apiClient.fetchModelFolders(); const folderBrowser = document.getElementById('folderBrowser'); - // Update folder browser with dynamic content folderBrowser.innerHTML = foldersData.folders.map(folder => `
${folder}
` ).join(''); - // Initialize folder browser after loading roots and folders this.initializeFolderBrowser(); } catch (error) { showToast(error.message, 'error'); @@ -300,12 +325,17 @@ export class DownloadManager { document.getElementById('versionStep').style.display = 'block'; } + closeModal() { + modalManager.closeModal('downloadModal'); + } + async startDownload() { - const loraRoot = document.getElementById('loraRoot').value; + const modelRoot = document.getElementById('modelRoot').value; const newFolder = document.getElementById('newFolder').value.trim(); + const config = this.apiClient.apiConfig.config; - if (!loraRoot) { - showToast('Please select a LoRA root directory', 'error'); + if (!modelRoot) { + showToast(`Please select a ${config.displayName} root directory`, 'error'); return; } @@ -320,38 +350,32 @@ export class DownloadManager { } try { - // Show enhanced loading with progress details const updateProgress = this.loadingManager.showDownloadProgress(1); updateProgress(0, 0, this.currentVersion.name); - // Generate a unique ID for this download const downloadId = Date.now().toString(); - // Setup WebSocket for progress updates - use download-specific endpoint + // Setup WebSocket for progress updates const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`); ws.onmessage = (event) => { const data = JSON.parse(event.data); - // Handle download ID confirmation if (data.type === 'download_id') { console.log(`Connected to download progress with ID: ${data.download_id}`); return; } - // Only process progress updates for our download if (data.status === 'progress' && data.download_id === downloadId) { - // Update progress display with current progress updateProgress(data.progress, 0, this.currentVersion.name); - // Add more detailed status messages based on progress if (data.progress < 3) { this.loadingManager.setStatus(`Preparing download...`); } else if (data.progress === 3) { this.loadingManager.setStatus(`Downloaded preview image`); } else if (data.progress > 3 && data.progress < 100) { - this.loadingManager.setStatus(`Downloading LoRA file`); + this.loadingManager.setStatus(`Downloading ${config.singularName} file`); } else { this.loadingManager.setStatus(`Finalizing download...`); } @@ -360,35 +384,47 @@ export class DownloadManager { ws.onerror = (error) => { console.error('WebSocket error:', error); - // Continue with download even if WebSocket fails }; - // Start download with our download ID - const response = await fetch('/api/download-model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model_id: this.modelId, - model_version_id: this.currentVersion.id, - model_root: loraRoot, - relative_path: targetFolder, - download_id: downloadId - }) - }); - - if (!response.ok) { - throw new Error(await response.text()); - } + // Start download + await this.apiClient.downloadModel( + this.modelId, + this.currentVersion.id, + modelRoot, + targetFolder, + downloadId + ); showToast('Download completed successfully', 'success'); modalManager.closeModal('downloadModal'); - // Close WebSocket after download completes ws.close(); - // Update state and trigger reload with folder update - state.activeFolder = targetFolder; - await resetAndReload(true); // Pass true to update folders + // Update state and trigger reload + const pageState = this.apiClient.getPageState(); + pageState.activeFolder = targetFolder; + + // Save the active folder preference + setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder); + + // Update UI folder selection + document.querySelectorAll('.folder-tags .tag').forEach(tag => { + const isActive = tag.dataset.folder === targetFolder; + tag.classList.toggle('active', isActive); + if (isActive && !tag.parentNode.classList.contains('collapsed')) { + tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + }); + + // Trigger reload with folder update - use dynamic import based on model type + const modelType = this.apiClient.modelType; + if (modelType === 'loras') { + const { resetAndReload } = await import('../api/loraApi.js'); + await resetAndReload(true); + } else if (modelType === 'checkpoints') { + const { resetAndReload } = await import('../api/checkpointApi.js'); + await resetAndReload(true); + } } catch (error) { showToast(error.message, 'error'); @@ -397,15 +433,12 @@ export class DownloadManager { } } - // Add new method to handle folder selection initializeFolderBrowser() { const folderBrowser = document.getElementById('folderBrowser'); if (!folderBrowser) return; - // Cleanup existing handler if any this.cleanupFolderBrowser(); - // Create new handler this.folderClickHandler = (event) => { const folderItem = event.target.closest('.folder-item'); if (!folderItem) return; @@ -420,21 +453,17 @@ export class DownloadManager { this.selectedFolder = folderItem.dataset.folder; } - // Update path display after folder selection this.updateTargetPath(); }; - // Add the new handler folderBrowser.addEventListener('click', this.folderClickHandler); - // Add event listeners for path updates - const loraRoot = document.getElementById('loraRoot'); + const modelRoot = document.getElementById('modelRoot'); const newFolder = document.getElementById('newFolder'); - loraRoot.addEventListener('change', this.updateTargetPath); + modelRoot.addEventListener('change', this.updateTargetPath); newFolder.addEventListener('input', this.updateTargetPath); - // Update initial path this.updateTargetPath(); } @@ -447,23 +476,22 @@ export class DownloadManager { } } - // Remove path update listeners - const loraRoot = document.getElementById('loraRoot'); + const modelRoot = document.getElementById('modelRoot'); const newFolder = document.getElementById('newFolder'); - loraRoot.removeEventListener('change', this.updateTargetPath); - newFolder.removeEventListener('input', this.updateTargetPath); + if (modelRoot) modelRoot.removeEventListener('change', this.updateTargetPath); + if (newFolder) newFolder.removeEventListener('input', this.updateTargetPath); } - // Add new method to update target path updateTargetPath() { const pathDisplay = document.getElementById('targetPathDisplay'); - const loraRoot = document.getElementById('loraRoot').value; + const modelRoot = document.getElementById('modelRoot').value; const newFolder = document.getElementById('newFolder').value.trim(); + const config = this.apiClient.apiConfig.config; - let fullPath = loraRoot || 'Select a LoRA root directory'; + let fullPath = modelRoot || `Select a ${config.displayName} root directory`; - if (loraRoot) { + if (modelRoot) { if (this.selectedFolder) { fullPath += '/' + this.selectedFolder; } @@ -475,3 +503,6 @@ export class DownloadManager { pathDisplay.innerHTML = `${fullPath}`; } } + +// Create global instance +export const downloadManager = new DownloadManager(); diff --git a/templates/checkpoints.html b/templates/checkpoints.html index 47f8295a..f65803ca 100644 --- a/templates/checkpoints.html +++ b/templates/checkpoints.html @@ -12,7 +12,6 @@ {% block init_check_url %}/api/checkpoints?page=1&page_size=1{% endblock %} {% block additional_components %} -{% include 'components/checkpoint_modals.html' %}