diff --git a/README.md b/README.md index 47f34919..531a3487 100644 --- a/README.md +++ b/README.md @@ -283,7 +283,16 @@ If you find this project helpful, consider supporting its development: [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/pixelpawsai) -WeChat & Alipay: [Click to view QR codes](https://raw.githubusercontent.com/willmiao/ComfyUI-Lora-Manager/main/static/images/combined-qr.webp) +
+ + + + +
+

WeChat:

+ WeChat QR +
+
## 💬 Community diff --git a/py/routes/api_routes.py b/py/routes/api_routes.py index 611f65c6..83d791a8 100644 --- a/py/routes/api_routes.py +++ b/py/routes/api_routes.py @@ -43,6 +43,7 @@ class ApiRoutes: app.on_startup.append(lambda _: routes.initialize_services()) app.router.add_post('/api/delete_model', routes.delete_model) + app.router.add_post('/api/loras/exclude', routes.exclude_model) # Add new exclude endpoint app.router.add_post('/api/fetch-civitai', routes.fetch_civitai) app.router.add_post('/api/replace_preview', routes.replace_preview) app.router.add_get('/api/loras', routes.get_loras) @@ -81,6 +82,12 @@ class ApiRoutes: self.scanner = await ServiceRegistry.get_lora_scanner() return await ModelRouteUtils.handle_delete_model(request, self.scanner) + async def exclude_model(self, request: web.Request) -> web.Response: + """Handle model exclusion request""" + if self.scanner is None: + self.scanner = await ServiceRegistry.get_lora_scanner() + return await ModelRouteUtils.handle_exclude_model(request, self.scanner) + async def fetch_civitai(self, request: web.Request) -> web.Response: """Handle CivitAI metadata fetch request""" if self.scanner is None: diff --git a/py/routes/checkpoints_routes.py b/py/routes/checkpoints_routes.py index 46752f28..1e361f71 100644 --- a/py/routes/checkpoints_routes.py +++ b/py/routes/checkpoints_routes.py @@ -49,6 +49,7 @@ class CheckpointsRoutes: # Add new routes for model management similar to LoRA routes app.router.add_post('/api/checkpoints/delete', self.delete_model) + app.router.add_post('/api/checkpoints/exclude', self.exclude_model) # Add new exclude endpoint app.router.add_post('/api/checkpoints/fetch-civitai', self.fetch_civitai) app.router.add_post('/api/checkpoints/replace-preview', self.replace_preview) app.router.add_post('/api/checkpoints/download', self.download_checkpoint) @@ -499,6 +500,10 @@ class CheckpointsRoutes: async def delete_model(self, request: web.Request) -> web.Response: """Handle checkpoint model deletion request""" return await ModelRouteUtils.handle_delete_model(request, self.scanner) + + async def exclude_model(self, request: web.Request) -> web.Response: + """Handle checkpoint model exclusion request""" + return await ModelRouteUtils.handle_exclude_model(request, self.scanner) async def fetch_civitai(self, request: web.Request) -> web.Response: """Handle CivitAI metadata fetch request for checkpoints""" @@ -653,7 +658,7 @@ class CheckpointsRoutes: model_type = response.get('type', '') # Check model type - should be Checkpoint - if model_type.lower() != 'checkpoint': + if (model_type.lower() != 'checkpoint'): return web.json_response({ 'error': f"Model type mismatch. Expected Checkpoint, got {model_type}" }, status=400) diff --git a/py/services/model_scanner.py b/py/services/model_scanner.py index cff319c0..38c28de7 100644 --- a/py/services/model_scanner.py +++ b/py/services/model_scanner.py @@ -38,6 +38,7 @@ class ModelScanner: self._hash_index = hash_index or ModelHashIndex() self._tags_count = {} # Dictionary to store tag counts self._is_initializing = False # Flag to track initialization state + self._excluded_models = [] # List to track excluded models # Register this service asyncio.create_task(self._register_service()) @@ -394,6 +395,9 @@ class ModelScanner: if file_path in cached_paths: found_paths.add(file_path) continue + + if file_path in self._excluded_models: + continue # Try case-insensitive match on Windows if os.name == 'nt': @@ -406,7 +410,7 @@ class ModelScanner: break if matched: continue - + # This is a new file to process new_files.append(file_path) @@ -586,6 +590,11 @@ class ModelScanner: model_data = metadata.to_dict() + # Skip excluded models + if model_data.get('exclude', False): + self._excluded_models.append(model_data['file_path']) + return None + await self._fetch_missing_metadata(file_path, model_data) rel_path = os.path.relpath(file_path, root_path) folder = os.path.dirname(rel_path) @@ -905,6 +914,10 @@ class ModelScanner: logger.error(f"Error getting model info by name: {e}", exc_info=True) return None + def get_excluded_models(self) -> List[str]: + """Get list of excluded model file paths""" + return self._excluded_models.copy() + async def update_preview_in_cache(self, file_path: str, preview_url: str) -> bool: """Update preview URL in cache for a specific lora @@ -918,4 +931,4 @@ class ModelScanner: if self._cache is None: return False - return await self._cache.update_preview_url(file_path, preview_url) \ No newline at end of file + return await self._cache.update_preview_url(file_path, preview_url) diff --git a/py/utils/models.py b/py/utils/models.py index f0f7fc94..17ef6900 100644 --- a/py/utils/models.py +++ b/py/utils/models.py @@ -23,6 +23,7 @@ class BaseModelMetadata: modelDescription: str = "" # Full model description civitai_deleted: bool = False # Whether deleted from Civitai favorite: bool = False # Whether the model is a favorite + exclude: bool = False # Whether to exclude this model from the cache def __post_init__(self): # Initialize empty lists to avoid mutable default parameter issue diff --git a/py/utils/routes_common.py b/py/utils/routes_common.py index f8b5f649..3d219440 100644 --- a/py/utils/routes_common.py +++ b/py/utils/routes_common.py @@ -425,6 +425,65 @@ class ModelRouteUtils: logger.error(f"Error replacing preview: {e}", exc_info=True) return web.Response(text=str(e), status=500) + @staticmethod + async def handle_exclude_model(request: web.Request, scanner) -> web.Response: + """Handle model exclusion request + + Args: + request: The aiohttp request + scanner: The model scanner instance with cache management methods + + Returns: + web.Response: The HTTP response + """ + try: + data = await request.json() + file_path = data.get('file_path') + if not file_path: + return web.Response(text='Model path is required', status=400) + + # Update metadata to mark as excluded + metadata_path = os.path.splitext(file_path)[0] + '.metadata.json' + metadata = await ModelRouteUtils.load_local_metadata(metadata_path) + metadata['exclude'] = True + + # Save updated metadata + with open(metadata_path, 'w', encoding='utf-8') as f: + json.dump(metadata, f, indent=2, ensure_ascii=False) + + # Update cache + cache = await scanner.get_cached_data() + + # Find and remove model from cache + model_to_remove = next((item for item in cache.raw_data if item['file_path'] == file_path), None) + if model_to_remove: + # Update tags count + for tag in model_to_remove.get('tags', []): + if tag in scanner._tags_count: + scanner._tags_count[tag] = max(0, scanner._tags_count[tag] - 1) + if scanner._tags_count[tag] == 0: + del scanner._tags_count[tag] + + # Remove from hash index if available + if hasattr(scanner, '_hash_index') and scanner._hash_index: + scanner._hash_index.remove_by_path(file_path) + + # Remove from cache data + cache.raw_data = [item for item in cache.raw_data if item['file_path'] != file_path] + await cache.resort() + + # Add to excluded models list + scanner._excluded_models.append(file_path) + + return web.json_response({ + 'success': True, + 'message': f"Model {os.path.basename(file_path)} excluded" + }) + + except Exception as e: + logger.error(f"Error excluding model: {e}", exc_info=True) + return web.Response(text=str(e), status=500) + @staticmethod async def handle_download_model(request: web.Request, download_manager: DownloadManager, model_type="lora") -> web.Response: """Handle model download request @@ -501,4 +560,4 @@ class ModelRouteUtils: ) logger.error(f"Error downloading {model_type}: {error_message}") - return web.Response(status=500, text=error_message) \ No newline at end of file + return web.Response(status=500, text=error_message) diff --git a/static/css/components/modal.css b/static/css/components/modal.css index e51ba923..09e3e12b 100644 --- a/static/css/components/modal.css +++ b/static/css/components/modal.css @@ -44,26 +44,12 @@ body.modal-open { } /* Delete Modal specific styles */ -.delete-modal-content { - max-width: 500px; - text-align: center; -} .delete-message { color: var(--text-color); margin: var(--space-2) 0; } -.delete-model-info { - 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; -} - /* Update delete modal styles */ .delete-modal { display: none; /* Set initial display to none */ @@ -92,7 +78,8 @@ body.modal-open { animation: modalFadeIn 0.2s ease-out; } -.delete-model-info { +.delete-model-info, +.exclude-model-info { /* Update info display styling */ background: var(--lora-surface); border: 1px solid var(--lora-border); @@ -123,7 +110,7 @@ body.modal-open { margin-top: var(--space-3); } -.cancel-btn, .delete-btn { +.cancel-btn, .delete-btn, .exclude-btn { padding: 8px var(--space-2); border-radius: 6px; border: none; @@ -143,6 +130,12 @@ body.modal-open { color: white; } +/* Style for exclude button - different from delete button */ +.exclude-btn { + background: var(--lora-accent, #4f46e5); + color: white; +} + .cancel-btn:hover { background: var(--lora-border); } @@ -151,6 +144,11 @@ body.modal-open { opacity: 0.9; } +.exclude-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-2); @@ -587,7 +585,7 @@ input:checked + .toggle-slider:before { border-radius: var(--border-radius-xs); border: 1px solid var(--border-color); background-color: var(--lora-surface); - color: var(--text-color); + color: var (--text-color); font-size: 0.95em; height: 32px; } diff --git a/static/css/components/support-modal.css b/static/css/components/support-modal.css index b16e3cbd..20e28009 100644 --- a/static/css/components/support-modal.css +++ b/static/css/components/support-modal.css @@ -151,11 +151,12 @@ } .qrcode-image { - max-width: 100%; + max-width: 80%; height: auto; border-radius: var(--border-radius-sm); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border: 1px solid var(--lora-border); + aspect-ratio: 1/1; /* Ensure proper aspect ratio for the square QR code */ } .support-footer { diff --git a/static/images/combined-qr.webp b/static/images/combined-qr.webp deleted file mode 100644 index f68eb2dc..00000000 Binary files a/static/images/combined-qr.webp and /dev/null differ diff --git a/static/images/wechat-qr.webp b/static/images/wechat-qr.webp new file mode 100644 index 00000000..7dbda3c5 Binary files /dev/null and b/static/images/wechat-qr.webp differ diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 60bc4b8a..46c13a42 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -208,13 +208,44 @@ export function replaceModelPreview(filePath, modelType = 'lora') { } // Delete a model (generic) -export function deleteModel(filePath, modelType = 'lora') { - if (modelType === 'checkpoint') { - confirmDelete('Are you sure you want to delete this checkpoint?', () => { - performDelete(filePath, modelType); +export async function deleteModel(filePath, modelType = 'lora') { + try { + const endpoint = modelType === 'checkpoint' + ? '/api/checkpoints/delete' + : '/api/delete_model'; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_path: filePath + }) }); - } else { - showDeleteModal(filePath); + + if (!response.ok) { + throw new Error(`Failed to delete ${modelType}: ${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'); + 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; } } @@ -394,6 +425,48 @@ export async function refreshSingleModelMetadata(filePath, modelType = 'lora') { } } +// Generic function to exclude a model +export async function excludeModel(filePath, modelType = 'lora') { + try { + 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) { + // Remove the card from UI + 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; + } +} + // Private methods // Upload a preview image diff --git a/static/js/api/checkpointApi.js b/static/js/api/checkpointApi.js index a1a59c96..c5ebcd3f 100644 --- a/static/js/api/checkpointApi.js +++ b/static/js/api/checkpointApi.js @@ -6,7 +6,8 @@ import { deleteModel as baseDeleteModel, replaceModelPreview, fetchCivitaiMetadata, - refreshSingleModelMetadata + refreshSingleModelMetadata, + excludeModel as baseExcludeModel } from './baseModelApi.js'; // Load more checkpoints with pagination @@ -85,4 +86,13 @@ export async function saveModelMetadata(filePath, data) { } return response.json(); +} + +/** + * 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'); } \ No newline at end of file diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js index 8c4cb9a5..80fa3fb7 100644 --- a/static/js/api/loraApi.js +++ b/static/js/api/loraApi.js @@ -6,7 +6,8 @@ import { deleteModel as baseDeleteModel, replaceModelPreview, fetchCivitaiMetadata, - refreshSingleModelMetadata + refreshSingleModelMetadata, + excludeModel as baseExcludeModel } from './baseModelApi.js'; /** @@ -34,6 +35,15 @@ export async function saveModelMetadata(filePath, data) { return response.json(); } +/** + * 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'); +} + export async function loadMoreLoras(resetPage = false, updateFolders = false) { return loadMoreModels({ resetPage, diff --git a/static/js/checkpoints.js b/static/js/checkpoints.js index 14036ff3..777277f2 100644 --- a/static/js/checkpoints.js +++ b/static/js/checkpoints.js @@ -1,6 +1,6 @@ import { appCore } from './core.js'; import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; -import { confirmDelete, closeDeleteModal } from './utils/modalUtils.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'; @@ -23,6 +23,8 @@ class CheckpointsPageManager { // Minimal set of functions that need to remain global window.confirmDelete = confirmDelete; window.closeDeleteModal = closeDeleteModal; + window.confirmExclude = confirmExclude; + window.closeExcludeModal = closeExcludeModal; // Add loadCheckpoints function to window for FilterManager compatibility window.checkpointManager = { diff --git a/static/js/components/CheckpointCard.js b/static/js/components/CheckpointCard.js index a07aee43..4b9b7d20 100644 --- a/static/js/components/CheckpointCard.js +++ b/static/js/components/CheckpointCard.js @@ -3,6 +3,7 @@ import { state } from '../state/index.js'; import { showCheckpointModal } from './checkpointModal/index.js'; import { NSFW_LEVELS } from '../utils/constants.js'; import { replaceCheckpointPreview as apiReplaceCheckpointPreview, saveModelMetadata } from '../api/checkpointApi.js'; +import { showDeleteModal } from '../utils/modalUtils.js'; export function createCheckpointCard(checkpoint) { const card = document.createElement('div'); @@ -262,7 +263,7 @@ export function createCheckpointCard(checkpoint) { // Delete button click event card.querySelector('.fa-trash')?.addEventListener('click', e => { e.stopPropagation(); - deleteCheckpoint(checkpoint.file_path); + showDeleteModal(checkpoint.file_path); }); // Replace preview button click event @@ -322,17 +323,6 @@ function openCivitai(modelName) { } } -function deleteCheckpoint(filePath) { - if (window.deleteCheckpoint) { - window.deleteCheckpoint(filePath); - } else { - // Use the modal delete functionality - import('../utils/modalUtils.js').then(({ showDeleteModal }) => { - showDeleteModal(filePath, 'checkpoint'); - }); - } -} - function replaceCheckpointPreview(filePath) { if (window.replaceCheckpointPreview) { window.replaceCheckpointPreview(filePath); diff --git a/static/js/components/ContextMenu/CheckpointContextMenu.js b/static/js/components/ContextMenu/CheckpointContextMenu.js index c0975bbe..e5e5aea0 100644 --- a/static/js/components/ContextMenu/CheckpointContextMenu.js +++ b/static/js/components/ContextMenu/CheckpointContextMenu.js @@ -3,6 +3,7 @@ import { refreshSingleCheckpointMetadata, saveModelMetadata } from '../../api/ch import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js'; import { NSFW_LEVELS } from '../../utils/constants.js'; import { getStorageItem } from '../../utils/storageHelpers.js'; +import { showExcludeModal } from '../../utils/modalUtils.js'; export class CheckpointContextMenu extends BaseContextMenu { constructor() { @@ -61,6 +62,10 @@ export class CheckpointContextMenu extends BaseContextMenu { // Move to folder (placeholder) showToast('Move to folder feature coming soon', 'info'); break; + case 'exclude': + showExcludeModal(this.currentCard.dataset.filepath, 'checkpoint'); + break; + } } diff --git a/static/js/components/ContextMenu/LoraContextMenu.js b/static/js/components/ContextMenu/LoraContextMenu.js index 146c3d94..9d72f99f 100644 --- a/static/js/components/ContextMenu/LoraContextMenu.js +++ b/static/js/components/ContextMenu/LoraContextMenu.js @@ -3,6 +3,7 @@ import { refreshSingleLoraMetadata, saveModelMetadata } from '../../api/loraApi. import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js'; import { NSFW_LEVELS } from '../../utils/constants.js'; import { getStorageItem } from '../../utils/storageHelpers.js'; +import { showExcludeModal } from '../../utils/modalUtils.js'; export class LoraContextMenu extends BaseContextMenu { constructor() { @@ -51,6 +52,9 @@ export class LoraContextMenu extends BaseContextMenu { case 'set-nsfw': this.showNSFWLevelSelector(null, null, this.currentCard); break; + case 'exclude': + showExcludeModal(this.currentCard.dataset.filepath); + break; } } diff --git a/static/js/components/Header.js b/static/js/components/Header.js index c6bb5e20..4de0e83d 100644 --- a/static/js/components/Header.js +++ b/static/js/components/Header.js @@ -90,7 +90,7 @@ export class HeaderManager { const toggleText = qrToggle.querySelector('.toggle-text'); if (qrContainer.classList.contains('show')) { - toggleText.textContent = 'Hide QR Codes'; + toggleText.textContent = 'Hide WeChat QR Code'; // Add small delay to ensure DOM is updated before scrolling setTimeout(() => { const supportModal = document.querySelector('.support-modal'); @@ -102,7 +102,7 @@ export class HeaderManager { } }, 250); } else { - toggleText.textContent = 'Show QR Codes'; + toggleText.textContent = 'Show WeChat QR Code'; } }); } diff --git a/static/js/components/LoraCard.js b/static/js/components/LoraCard.js index b5add51d..26c54458 100644 --- a/static/js/components/LoraCard.js +++ b/static/js/components/LoraCard.js @@ -3,7 +3,8 @@ import { state } from '../state/index.js'; import { showLoraModal } from './loraModal/index.js'; import { bulkManager } from '../managers/BulkManager.js'; import { NSFW_LEVELS } from '../utils/constants.js'; -import { replacePreview, deleteModel, saveModelMetadata } from '../api/loraApi.js' +import { replacePreview, saveModelMetadata } from '../api/loraApi.js' +import { showDeleteModal } from '../utils/modalUtils.js'; export function createLoraCard(lora) { const card = document.createElement('div'); @@ -260,7 +261,7 @@ export function createLoraCard(lora) { // Delete button click event card.querySelector('.fa-trash')?.addEventListener('click', e => { e.stopPropagation(); - deleteModel(lora.file_path); + showDeleteModal(lora.file_path); }); // Replace preview button click event diff --git a/static/js/components/checkpointModal/ShowcaseView.js b/static/js/components/checkpointModal/ShowcaseView.js index bc0c96ac..86f539d1 100644 --- a/static/js/components/checkpointModal/ShowcaseView.js +++ b/static/js/components/checkpointModal/ShowcaseView.js @@ -328,6 +328,8 @@ function initMetadataPanelHandlers(container) { if (!metadataPanel || !mediaElement) return; + let isOverMetadataPanel = false; + // Add event listeners to the wrapper for mouse tracking wrapper.addEventListener('mousemove', (e) => { // Get mouse position relative to wrapper @@ -346,8 +348,8 @@ function initMetadataPanelHandlers(container) { mouseY <= mediaRect.bottom ); - // Show metadata panel only when over actual media content - if (isOverMedia) { + // Show metadata panel when over media content or metadata panel itself + if (isOverMedia || isOverMetadataPanel) { metadataPanel.classList.add('visible'); } else { metadataPanel.classList.remove('visible'); @@ -355,8 +357,36 @@ function initMetadataPanelHandlers(container) { }); wrapper.addEventListener('mouseleave', () => { - // Hide panel when mouse leaves the wrapper - metadataPanel.classList.remove('visible'); + // Only hide panel when mouse leaves the wrapper and not over the metadata panel + if (!isOverMetadataPanel) { + metadataPanel.classList.remove('visible'); + } + }); + + // Add mouse enter/leave events for the metadata panel itself + metadataPanel.addEventListener('mouseenter', () => { + isOverMetadataPanel = true; + metadataPanel.classList.add('visible'); + }); + + metadataPanel.addEventListener('mouseleave', () => { + isOverMetadataPanel = false; + // Only hide if mouse is not over the media + const rect = wrapper.getBoundingClientRect(); + const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + + const isOverMedia = ( + mouseX >= mediaRect.left && + mouseX <= mediaRect.right && + mouseY >= mediaRect.top && + mouseY <= mediaRect.bottom + ); + + if (!isOverMedia) { + metadataPanel.classList.remove('visible'); + } }); // Prevent events from bubbling @@ -386,8 +416,14 @@ function initMetadataPanelHandlers(container) { // Prevent panel scroll from causing modal scroll metadataPanel.addEventListener('wheel', (e) => { - e.stopPropagation(); - }); + const isAtTop = metadataPanel.scrollTop === 0; + const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight; + + // Only prevent default if scrolling would cause the panel to scroll + if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) { + e.stopPropagation(); + } + }, { passive: true }); }); } diff --git a/static/js/components/loraModal/ShowcaseView.js b/static/js/components/loraModal/ShowcaseView.js index 6c264408..18d61a8a 100644 --- a/static/js/components/loraModal/ShowcaseView.js +++ b/static/js/components/loraModal/ShowcaseView.js @@ -335,6 +335,8 @@ function initMetadataPanelHandlers(container) { if (!metadataPanel || !mediaElement) return; + let isOverMetadataPanel = false; + // Add event listeners to the wrapper for mouse tracking wrapper.addEventListener('mousemove', (e) => { // Get mouse position relative to wrapper @@ -353,8 +355,8 @@ function initMetadataPanelHandlers(container) { mouseY <= mediaRect.bottom ); - // Show metadata panel only when over actual media content - if (isOverMedia) { + // Show metadata panel when over media content + if (isOverMedia || isOverMetadataPanel) { metadataPanel.classList.add('visible'); } else { metadataPanel.classList.remove('visible'); @@ -362,8 +364,36 @@ function initMetadataPanelHandlers(container) { }); wrapper.addEventListener('mouseleave', () => { - // Hide panel when mouse leaves the wrapper - metadataPanel.classList.remove('visible'); + // Only hide panel when mouse leaves the wrapper and not over the metadata panel + if (!isOverMetadataPanel) { + metadataPanel.classList.remove('visible'); + } + }); + + // Add mouse enter/leave events for the metadata panel itself + metadataPanel.addEventListener('mouseenter', () => { + isOverMetadataPanel = true; + metadataPanel.classList.add('visible'); + }); + + metadataPanel.addEventListener('mouseleave', () => { + isOverMetadataPanel = false; + // Only hide if mouse is not over the media + const rect = wrapper.getBoundingClientRect(); + const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + + const isOverMedia = ( + mouseX >= mediaRect.left && + mouseX <= mediaRect.right && + mouseY >= mediaRect.top && + mouseY <= mediaRect.bottom + ); + + if (!isOverMedia) { + metadataPanel.classList.remove('visible'); + } }); // Prevent events from the metadata panel from bubbling diff --git a/static/js/loras.js b/static/js/loras.js index 96f0af37..5dcc9638 100644 --- a/static/js/loras.js +++ b/static/js/loras.js @@ -8,7 +8,7 @@ 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'; -import { confirmDelete, closeDeleteModal } from './utils/modalUtils.js'; +import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js'; // Initialize the LoRA page class LoraPageManager { @@ -35,6 +35,8 @@ class LoraPageManager { window.showLoraModal = showLoraModal; window.confirmDelete = confirmDelete; window.closeDeleteModal = closeDeleteModal; + window.confirmExclude = confirmExclude; + window.closeExcludeModal = closeExcludeModal; window.downloadManager = this.downloadManager; window.moveManager = moveManager; window.toggleShowcase = toggleShowcase; diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index 989a2806..8b1d5b6d 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -59,6 +59,19 @@ export class ModalManager { } }); } + + // Add excludeModal registration + const excludeModal = document.getElementById('excludeModal'); + if (excludeModal) { + this.registerModal('excludeModal', { + element: excludeModal, + onClose: () => { + this.getModal('excludeModal').element.classList.remove('show'); + document.body.classList.remove('modal-open'); + }, + closeOnOutsideClick: true + }); + } // Add downloadModal registration const downloadModal = document.getElementById('downloadModal'); @@ -208,7 +221,7 @@ export class ModalManager { // Store current scroll position before showing modal this.scrollPosition = window.scrollY; - if (id === 'deleteModal') { + if (id === 'deleteModal' || id === 'excludeModal') { modal.element.classList.add('show'); } else { modal.element.style.display = 'block'; diff --git a/static/js/utils/modalUtils.js b/static/js/utils/modalUtils.js index be5fe25d..0bb098f6 100644 --- a/static/js/utils/modalUtils.js +++ b/static/js/utils/modalUtils.js @@ -1,15 +1,18 @@ import { modalManager } from '../managers/ModalManager.js'; +import { excludeLora, deleteModel as deleteLora } from '../api/loraApi.js'; +import { excludeCheckpoint, deleteCheckpoint } from '../api/checkpointApi.js'; let pendingDeletePath = null; let pendingModelType = null; +let pendingExcludePath = null; +let pendingExcludeModelType = null; export function showDeleteModal(filePath, modelType = 'lora') { - // event.stopPropagation(); pendingDeletePath = filePath; pendingModelType = modelType; const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); - const modelName = card.dataset.name; + const modelName = card ? card.dataset.name : filePath.split('/').pop(); const modal = modalManager.getModal('deleteModal').element; const modelInfo = modal.querySelector('.delete-model-info'); @@ -28,31 +31,19 @@ export async function confirmDelete() { const card = document.querySelector(`.lora-card[data-filepath="${pendingDeletePath}"]`); try { - // Use the appropriate endpoint based on model type - const endpoint = pendingModelType === 'checkpoint' ? - '/api/checkpoints/delete' : - '/api/delete_model'; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: pendingDeletePath - }) - }); - - if (response.ok) { - if (card) { - card.remove(); - } - closeDeleteModal(); + // Use appropriate delete function based on model type + if (pendingModelType === 'checkpoint') { + await deleteCheckpoint(pendingDeletePath); } else { - const error = await response.text(); - alert(`Failed to delete model: ${error}`); + await deleteLora(pendingDeletePath); } + + if (card) { + card.remove(); + } + closeDeleteModal(); } catch (error) { + console.error('Error deleting model:', error); alert(`Error deleting model: ${error}`); } } @@ -61,4 +52,46 @@ export function closeDeleteModal() { modalManager.closeModal('deleteModal'); pendingDeletePath = null; pendingModelType = null; +} + +// Functions for the exclude modal +export function showExcludeModal(filePath, modelType = 'lora') { + pendingExcludePath = filePath; + pendingExcludeModelType = modelType; + + const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + const modelName = card ? card.dataset.name : filePath.split('/').pop(); + const modal = modalManager.getModal('excludeModal').element; + const modelInfo = modal.querySelector('.exclude-model-info'); + + modelInfo.innerHTML = ` + Model: ${modelName} +
+ File: ${filePath} + `; + + modalManager.showModal('excludeModal'); +} + +export function closeExcludeModal() { + modalManager.closeModal('excludeModal'); + pendingExcludePath = null; + pendingExcludeModelType = null; +} + +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); + } + + closeExcludeModal(); + } catch (error) { + console.error('Error excluding model:', error); + } } \ No newline at end of file diff --git a/templates/checkpoints.html b/templates/checkpoints.html index 1644d8d5..5eb3371d 100644 --- a/templates/checkpoints.html +++ b/templates/checkpoints.html @@ -15,7 +15,7 @@ {% include 'components/checkpoint_modals.html' %} {% endblock %} diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index bb980c9d..b58479b9 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -1,7 +1,7 @@
-
+
View on Civitai
@@ -21,6 +21,9 @@
Move to Folder
+
+ Exclude Model +
Delete Model
diff --git a/templates/components/modals.html b/templates/components/modals.html index 0e3c4ee1..0f822086 100644 --- a/templates/components/modals.html +++ b/templates/components/modals.html @@ -11,6 +11,19 @@
+ + +