From 0c4914909a45f98c2ede2d25b7780a6d9b87882e Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Fri, 7 Mar 2025 13:15:39 +0800 Subject: [PATCH] Add bulk operation --- py/routes/api_routes.py | 37 ++++++++ static/css/layout.css | 134 +++++++++++++++++++++++++++++ static/js/components/LoraCard.js | 68 +++++++++++---- static/js/main.js | 22 ++++- static/js/managers/BulkManager.js | 106 +++++++++++++++++++++++ static/js/managers/MoveManager.js | 112 ++++++++++++++++++------ static/js/state/index.js | 3 +- templates/components/controls.html | 23 +++++ templates/components/modals.html | 6 +- templates/loras.html | 4 + 10 files changed, 471 insertions(+), 44 deletions(-) create mode 100644 static/js/managers/BulkManager.js diff --git a/py/routes/api_routes.py b/py/routes/api_routes.py index 479ea5ed..01666146 100644 --- a/py/routes/api_routes.py +++ b/py/routes/api_routes.py @@ -43,6 +43,7 @@ class ApiRoutes: app.router.add_post('/api/move_model', routes.move_model) app.router.add_post('/loras/api/save-metadata', routes.save_metadata) app.router.add_get('/api/lora-preview-url', routes.get_lora_preview_url) # Add new route + app.router.add_post('/api/move_models_bulk', routes.move_models_bulk) # Add update check routes UpdateRoutes.setup_routes(app) @@ -654,3 +655,39 @@ class ApiRoutes: except Exception as e: logger.error(f"Error getting lora preview URL: {e}", exc_info=True) return web.Response(text=str(e), status=500) + + async def move_models_bulk(self, request: web.Request) -> web.Response: + """Handle bulk model move request""" + try: + data = await request.json() + file_paths = data.get('file_paths', []) + target_path = data.get('target_path') + + if not file_paths or not target_path: + return web.Response(text='File paths and target path are required', status=400) + + results = [] + for file_path in file_paths: + success = await self.scanner.move_model(file_path, target_path) + results.append({"path": file_path, "success": success}) + + # Count successes + success_count = sum(1 for r in results if r["success"]) + + if success_count == len(file_paths): + return web.json_response({ + 'success': True, + 'message': f'Successfully moved {success_count} models' + }) + elif success_count > 0: + return web.json_response({ + 'success': True, + 'message': f'Moved {success_count} of {len(file_paths)} models', + 'results': results + }) + else: + return web.Response(text='Failed to move any models', status=500) + + except Exception as e: + logger.error(f"Error moving models in bulk: {e}", exc_info=True) + return web.Response(text=str(e), status=500) diff --git a/static/css/layout.css b/static/css/layout.css index 32d91605..f1331576 100644 --- a/static/css/layout.css +++ b/static/css/layout.css @@ -385,4 +385,138 @@ .back-to-top { bottom: 60px; /* Give some extra space from bottom on mobile */ } +} + +/* Bulk Operations Styles */ +.bulk-operations-panel { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateY(100px) translateX(-50%); + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-base); + padding: 12px 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + z-index: var(--z-overlay); + display: flex; + flex-direction: column; + min-width: 300px; + transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); + opacity: 0; +} + +.bulk-operations-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + gap: 20px; /* Increase space between count and buttons */ +} + +#selectedCount { + font-weight: 500; + background: var(--bg-color); + padding: 6px 12px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + min-width: 80px; + text-align: center; +} + +.bulk-operations-actions { + display: flex; + gap: 8px; +} + +.bulk-operations-actions button { + padding: 6px 12px; + border-radius: var(--border-radius-xs); + background: var(--bg-color); + border: 1px solid var(--border-color); + color: var(--text-color); + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.2s ease; +} + +.bulk-operations-actions button:hover { + background: var(--lora-accent); + color: white; + border-color: var(--lora-accent); +} + +/* Style for selected cards */ +.lora-card.selected { + box-shadow: 0 0 0 2px var(--lora-accent); + position: relative; +} + +.lora-card.selected::after { + content: "✓"; + position: absolute; + top: 10px; + right: 10px; + width: 24px; + height: 24px; + background: var(--lora-accent); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + z-index: 1; +} + +/* Standardize button widths in controls */ +.control-group button { + min-width: 100px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +/* Update bulk operations button to match others when active */ +#bulkOperationsBtn.active { + background: var(--lora-accent); + color: white; + border-color: var(--lora-accent); +} + +@media (max-width: 768px) { + .bulk-operations-panel { + width: calc(100% - 40px); + left: 20px; + transform: none; + border-radius: var(--border-radius-sm); + } + + .bulk-operations-actions { + flex-wrap: wrap; + } +} + +.bulk-operations-panel.visible { + transform: translateY(0) translateX(-50%); + opacity: 1; +} + +/* Remove the page overlay */ +.bulk-mode-overlay { + display: none; /* Hide the overlay completely */ +} + +/* Remove card scaling in bulk mode but keep the transition for other properties */ +.lora-card { + transition: box-shadow 0.3s ease; +} + +/* Remove the transform scale from bulk mode cards */ +.bulk-mode .lora-card { + transform: none; } \ No newline at end of file diff --git a/static/js/components/LoraCard.js b/static/js/components/LoraCard.js index ee237af1..d839b242 100644 --- a/static/js/components/LoraCard.js +++ b/static/js/components/LoraCard.js @@ -60,23 +60,30 @@ export function createLoraCard(lora) { `; - // Main card click event + // Main card click event - modified to handle bulk mode card.addEventListener('click', () => { - const loraMeta = { - sha256: card.dataset.sha256, - file_path: card.dataset.filepath, - model_name: card.dataset.name, - file_name: card.dataset.file_name, - folder: card.dataset.folder, - modified: card.dataset.modified, - file_size: card.dataset.file_size, - from_civitai: card.dataset.from_civitai === 'true', - base_model: card.dataset.base_model, - usage_tips: card.dataset.usage_tips, - notes: card.dataset.notes, - civitai: JSON.parse(card.dataset.meta || '{}') - }; - showLoraModal(loraMeta); + // Check if we're in bulk mode + if (state.bulkMode) { + // Toggle selection + toggleCardSelection(card); + } else { + // Normal behavior - show modal + const loraMeta = { + sha256: card.dataset.sha256, + file_path: card.dataset.filepath, + model_name: card.dataset.name, + file_name: card.dataset.file_name, + folder: card.dataset.folder, + modified: card.dataset.modified, + file_size: card.dataset.file_size, + from_civitai: card.dataset.from_civitai === 'true', + base_model: card.dataset.base_model, + usage_tips: card.dataset.usage_tips, + notes: card.dataset.notes, + civitai: JSON.parse(card.dataset.meta || '{}') + }; + showLoraModal(loraMeta); + } }); // Copy button click event @@ -127,6 +134,35 @@ export function createLoraCard(lora) { e.stopPropagation(); replacePreview(lora.file_path); }); + + // Apply bulk mode styling if currently in bulk mode + if (state.bulkMode) { + const actions = card.querySelectorAll('.card-actions'); + actions.forEach(actionGroup => { + actionGroup.style.display = 'none'; + }); + } return card; +} + +// Function to toggle selection of a card +function toggleCardSelection(card) { + card.classList.toggle('selected'); + updateSelectedCount(); +} + +// Add a method to update card appearance based on bulk mode +export function updateCardsForBulkMode(isBulkMode) { + // Update the state + state.bulkMode = isBulkMode; + + document.body.classList.toggle('bulk-mode', isBulkMode); + + document.querySelectorAll('.lora-card').forEach(card => { + const actions = card.querySelectorAll('.card-actions'); + actions.forEach(actionGroup => { + actionGroup.style.display = isBulkMode ? 'none' : 'flex'; + }); + }); } \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js index cb674c68..aab8acaf 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -27,8 +27,14 @@ import { SettingsManager, toggleApiKeyVisibility } from './managers/SettingsMana import { LoraContextMenu } from './components/ContextMenu.js'; import { moveManager } from './managers/MoveManager.js'; import { FilterManager } from './managers/FilterManager.js'; +import { createLoraCard, updateCardsForBulkMode } from './components/LoraCard.js'; +import { bulkManager } from './managers/BulkManager.js'; -// Export all functions that need global access +// Add bulk mode to state +state.bulkMode = false; +state.selectedLoras = new Set(); + +// Export functions to global window object window.loadMoreLoras = loadMoreLoras; window.fetchCivitai = fetchCivitai; window.deleteModel = deleteModel; @@ -51,6 +57,14 @@ window.moveManager = moveManager; window.toggleShowcase = toggleShowcase; window.scrollToTop = scrollToTop; +// Export bulk manager methods to window +window.toggleBulkMode = () => bulkManager.toggleBulkMode(); +window.clearSelection = () => bulkManager.clearSelection(); +window.toggleCardSelection = (card) => bulkManager.toggleCardSelection(card); +window.copyAllLorasSyntax = () => bulkManager.copyAllLorasSyntax(); +window.updateSelectedCount = () => bulkManager.updateSelectedCount(); +window.bulkManager = bulkManager; + // Initialize everything when DOM is ready document.addEventListener('DOMContentLoaded', async () => { state.loadingManager = new LoadingManager(); @@ -73,6 +87,12 @@ document.addEventListener('DOMContentLoaded', async () => { initBackToTop(); window.searchManager = new SearchManager(); new LoraContextMenu(); + + // Initialize cards for current bulk mode state (should be false initially) + updateCardsForBulkMode(state.bulkMode); + + // Initialize the bulk manager + bulkManager.initialize(); }); // Initialize event listeners diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js new file mode 100644 index 00000000..349cc12a --- /dev/null +++ b/static/js/managers/BulkManager.js @@ -0,0 +1,106 @@ +import { state } from '../state/index.js'; +import { showToast } from '../utils/uiHelpers.js'; +import { updateCardsForBulkMode } from '../components/LoraCard.js'; + +export class BulkManager { + constructor() { + this.bulkBtn = document.getElementById('bulkOperationsBtn'); + this.bulkPanel = document.getElementById('bulkOperationsPanel'); + + // Initialize selected loras set in state if not already there + if (!state.selectedLoras) { + state.selectedLoras = new Set(); + } + } + + initialize() { + // Add event listeners if needed + // (Already handled via onclick attributes in HTML, but could be moved here) + } + + toggleBulkMode() { + // Toggle the state + state.bulkMode = !state.bulkMode; + + // Update UI + this.bulkBtn.classList.toggle('active', state.bulkMode); + + // Important: Remove the hidden class when entering bulk mode + if (state.bulkMode) { + this.bulkPanel.classList.remove('hidden'); + // Use setTimeout to ensure the DOM updates before adding visible class + // This helps with the transition animation + setTimeout(() => { + this.bulkPanel.classList.add('visible'); + }, 10); + } else { + this.bulkPanel.classList.remove('visible'); + // Add hidden class back after transition completes + setTimeout(() => { + this.bulkPanel.classList.add('hidden'); + }, 400); // Match this with the transition duration in CSS + } + + // Update all cards + updateCardsForBulkMode(state.bulkMode); + + // Clear selection if exiting bulk mode + if (!state.bulkMode) { + this.clearSelection(); + } + } + + clearSelection() { + document.querySelectorAll('.lora-card.selected').forEach(card => { + card.classList.remove('selected'); + }); + state.selectedLoras.clear(); + this.updateSelectedCount(); + } + + updateSelectedCount() { + const selectedCards = document.querySelectorAll('.lora-card.selected'); + const countElement = document.getElementById('selectedCount'); + + if (countElement) { + countElement.textContent = `${selectedCards.length} selected`; + } + + // Update state with selected loras + state.selectedLoras.clear(); + selectedCards.forEach(card => { + state.selectedLoras.add(card.dataset.filepath); + }); + } + + toggleCardSelection(card) { + card.classList.toggle('selected'); + this.updateSelectedCount(); + } + + async copyAllLorasSyntax() { + const selectedCards = document.querySelectorAll('.lora-card.selected'); + if (selectedCards.length === 0) { + showToast('No LoRAs selected', 'warning'); + return; + } + + const loraSyntaxes = []; + selectedCards.forEach(card => { + const usageTips = JSON.parse(card.dataset.usage_tips || '{}'); + const strength = usageTips.strength || 1; + loraSyntaxes.push(``); + }); + + try { + await navigator.clipboard.writeText(loraSyntaxes.join(', ')); + showToast(`Copied ${selectedCards.length} LoRA syntaxes to clipboard`, 'success'); + } catch (err) { + console.error('Copy failed:', err); + showToast('Copy failed', 'error'); + } + } +} + +// Create a singleton instance +export const bulkManager = new BulkManager(); \ No newline at end of file diff --git a/static/js/managers/MoveManager.js b/static/js/managers/MoveManager.js index b7874a3f..c2bb51ea 100644 --- a/static/js/managers/MoveManager.js +++ b/static/js/managers/MoveManager.js @@ -5,11 +5,13 @@ import { modalManager } from './ModalManager.js'; class MoveManager { constructor() { this.currentFilePath = null; + this.bulkFilePaths = null; this.modal = document.getElementById('moveModal'); this.loraRootSelect = document.getElementById('moveLoraRoot'); this.folderBrowser = document.getElementById('moveFolderBrowser'); this.newFolderInput = document.getElementById('moveNewFolder'); this.pathDisplay = document.getElementById('moveTargetPathDisplay'); + this.modalTitle = document.getElementById('moveModalTitle'); this.initializeEventListeners(); } @@ -43,7 +45,24 @@ class MoveManager { } async showMoveModal(filePath) { - this.currentFilePath = filePath; + // Reset state + this.currentFilePath = null; + this.bulkFilePaths = null; + + // Handle bulk mode + if (filePath === 'bulk') { + const selectedPaths = Array.from(state.selectedLoras); + if (selectedPaths.length === 0) { + showToast('No LoRAs selected', 'warning'); + return; + } + this.bulkFilePaths = selectedPaths; + this.modalTitle.textContent = `Move ${selectedPaths.length} LoRAs`; + } else { + // Single file mode + this.currentFilePath = filePath; + this.modalTitle.textContent = "Move Model"; + } // 清除之前的选择 this.folderBrowser.querySelectorAll('.folder-item').forEach(item => { @@ -105,36 +124,81 @@ class MoveManager { targetPath = `${targetPath}/${newFolder}`; } + try { + if (this.bulkFilePaths) { + // Bulk move mode + await this.moveBulkModels(this.bulkFilePaths, targetPath); + } else { + // Single move mode + await this.moveSingleModel(this.currentFilePath, targetPath); + } + + modalManager.closeModal('moveModal'); + await resetAndReload(true); + + // If we were in bulk mode, exit it after successful move + if (this.bulkFilePaths && state.bulkMode) { + toggleBulkMode(); + } + + } catch (error) { + console.error('Error moving model(s):', error); + showToast('Failed to move model(s): ' + error.message, 'error'); + } + } + + async moveSingleModel(filePath, targetPath) { // show toast if current path is same as target path - if (this.currentFilePath.substring(0, this.currentFilePath.lastIndexOf('/')) === targetPath) { + if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) { showToast('Model is already in the selected folder', 'info'); return; } - try { - const response = await fetch('/api/move_model', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: this.currentFilePath, - target_path: targetPath - }) - }); + const response = await fetch('/api/move_model', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_path: filePath, + target_path: targetPath + }) + }); - if (!response.ok) { - throw new Error('Failed to move model'); - } - - showToast('Model moved successfully', 'success'); - modalManager.closeModal('moveModal'); - await resetAndReload(true); - - } catch (error) { - console.error('Error moving model:', error); - showToast('Failed to move model: ' + error.message, 'error'); + if (!response.ok) { + throw new Error('Failed to move model'); } + + 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 + }) + }); + + if (!response.ok) { + throw new Error('Failed to move models'); + } + + showToast(`Successfully moved ${movedPaths.length} models`, 'success'); } } diff --git a/static/js/state/index.js b/static/js/state/index.js index 746e2909..db474931 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -10,5 +10,6 @@ export const state = { searchManager: null, filters: { baseModel: [] - } + }, + bulkMode: false }; \ No newline at end of file diff --git a/templates/components/controls.html b/templates/components/controls.html index bd79d1cd..350b5343 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -28,6 +28,11 @@ Download +
+ +
@@ -62,4 +67,22 @@ Clear All Filters
+ + + + \ No newline at end of file diff --git a/templates/components/modals.html b/templates/components/modals.html index 0905e2d1..78e3affa 100644 --- a/templates/components/modals.html +++ b/templates/components/modals.html @@ -86,8 +86,10 @@