From 9bebcc9a4be92e41de766de5ea4679d4be083919 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Thu, 4 Sep 2025 15:24:54 +0800 Subject: [PATCH 1/9] feat(bulk): implement bulk context menu for model operations and remove bulk operations panel --- static/css/components/menu.css | 23 +- .../components/ContextMenu/BulkContextMenu.js | 123 +++++++++ static/js/core.js | 5 + static/js/managers/BulkManager.js | 249 ++---------------- templates/components/context_menu.html | 28 ++ templates/components/controls.html | 29 -- templates/loras.html | 2 - 7 files changed, 192 insertions(+), 267 deletions(-) create mode 100644 static/js/components/ContextMenu/BulkContextMenu.js diff --git a/static/css/components/menu.css b/static/css/components/menu.css index 566f71db..481bb213 100644 --- a/static/css/components/menu.css +++ b/static/css/components/menu.css @@ -176,11 +176,6 @@ background: linear-gradient(45deg, #4a90e2, #357abd); } -/* Remove old node-color-indicator styles */ -.node-color-indicator { - display: none; -} - .send-all-item { border-top: 1px solid var(--border-color); font-weight: 500; @@ -217,4 +212,22 @@ font-size: 12px; color: var(--text-muted); font-style: italic; +} + +/* Bulk Context Menu Header */ +.bulk-context-header { + padding: 10px 12px; + background: var(--lora-accent); + color: var(--lora-text); + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + font-size: 14px; + border-radius: var(--border-radius-xs) var(--border-radius-xs) 0 0; +} + +.bulk-context-header i { + width: 16px; + text-align: center; } \ No newline at end of file diff --git a/static/js/components/ContextMenu/BulkContextMenu.js b/static/js/components/ContextMenu/BulkContextMenu.js new file mode 100644 index 00000000..ef206b77 --- /dev/null +++ b/static/js/components/ContextMenu/BulkContextMenu.js @@ -0,0 +1,123 @@ +import { BaseContextMenu } from './BaseContextMenu.js'; +import { state } from '../../state/index.js'; +import { bulkManager } from '../../managers/BulkManager.js'; +import { translate, updateElementText } from '../../utils/i18nHelpers.js'; +import { MODEL_TYPES } from '../../api/apiConfig.js'; + +export class BulkContextMenu extends BaseContextMenu { + constructor() { + super('bulkContextMenu', '.model-card.selected'); + this.setupBulkMenuItems(); + } + + init() { + // Override parent init to handle bulk-specific context menu logic + document.addEventListener('click', () => this.hideMenu()); + + document.addEventListener('contextmenu', (e) => { + const card = e.target.closest('.model-card'); + if (!card || !state.bulkMode) { + this.hideMenu(); + return; + } + + // Show bulk menu only if right-clicking on a selected card + if (card.classList.contains('selected')) { + e.preventDefault(); + this.showMenu(e.clientX, e.clientY, card); + } else { + this.hideMenu(); + } + }); + + // Handle menu item clicks + this.menu.addEventListener('click', (e) => { + const menuItem = e.target.closest('.context-menu-item'); + if (!menuItem || !this.currentCard) return; + + const action = menuItem.dataset.action; + if (!action) return; + + this.handleMenuAction(action, menuItem); + this.hideMenu(); + }); + } + + setupBulkMenuItems() { + if (!this.menu) return; + + // Update menu items visibility based on current model type + this.updateMenuItemsForModelType(); + + // Update selected count in header + this.updateSelectedCountHeader(); + } + + updateMenuItemsForModelType() { + const currentModelType = state.currentPageType; + const config = bulkManager.actionConfig[currentModelType]; + + if (!config) return; + + // Update button visibility based on model type + const sendToWorkflowItem = this.menu.querySelector('[data-action="send-to-workflow"]'); + const copyAllItem = this.menu.querySelector('[data-action="copy-all"]'); + const refreshAllItem = this.menu.querySelector('[data-action="refresh-all"]'); + const moveAllItem = this.menu.querySelector('[data-action="move-all"]'); + const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]'); + + if (sendToWorkflowItem) { + sendToWorkflowItem.style.display = config.sendToWorkflow ? 'flex' : 'none'; + } + if (copyAllItem) { + copyAllItem.style.display = config.copyAll ? 'flex' : 'none'; + } + if (refreshAllItem) { + refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none'; + } + if (moveAllItem) { + moveAllItem.style.display = config.moveAll ? 'flex' : 'none'; + } + if (deleteAllItem) { + deleteAllItem.style.display = config.deleteAll ? 'flex' : 'none'; + } + } + + updateSelectedCountHeader() { + const headerElement = this.menu.querySelector('.bulk-context-header'); + if (headerElement) { + updateElementText(headerElement, 'loras.bulkOperations.selected', { count: state.selectedModels.size }); + } + } + + showMenu(x, y, card) { + this.updateMenuItemsForModelType(); + this.updateSelectedCountHeader(); + super.showMenu(x, y, card); + } + + handleMenuAction(action, menuItem) { + switch (action) { + case 'send-to-workflow': + bulkManager.sendAllModelsToWorkflow(); + break; + case 'copy-all': + bulkManager.copyAllModelsSyntax(); + break; + case 'refresh-all': + bulkManager.refreshAllMetadata(); + break; + case 'move-all': + window.moveManager.showMoveModal('bulk'); + break; + case 'delete-all': + bulkManager.showBulkDeleteModal(); + break; + case 'clear': + bulkManager.clearSelection(); + break; + default: + console.warn(`Unknown bulk action: ${action}`); + } + } +} diff --git a/static/js/core.js b/static/js/core.js index 1a250aca..21839d97 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -15,6 +15,7 @@ import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; import { migrateStorageItems } from './utils/storageHelpers.js'; import { i18n } from './i18n/index.js'; import { onboardingManager } from './managers/OnboardingManager.js'; +import { BulkContextMenu } from './components/ContextMenu/BulkContextMenu.js'; // Core application class export class AppCore { @@ -55,6 +56,10 @@ export class AppCore { // Initialize the bulk manager bulkManager.initialize(); + // Initialize bulk context menu + const bulkContextMenu = new BulkContextMenu(); + bulkManager.setBulkContextMenu(bulkContextMenu); + // Initialize the example images manager exampleImagesManager.initialize(); // Initialize the help manager diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index 5dc2bc97..8946bc11 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -2,18 +2,14 @@ import { state, getCurrentPageState } from '../state/index.js'; import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js'; import { updateCardsForBulkMode } from '../components/shared/ModelCard.js'; import { modalManager } from './ModalManager.js'; -import { moveManager } from './MoveManager.js'; import { getModelApiClient } from '../api/modelApiFactory.js'; import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js'; -import { updateElementText } from '../utils/i18nHelpers.js'; export class BulkManager { constructor() { this.bulkBtn = document.getElementById('bulkOperationsBtn'); - this.bulkPanel = document.getElementById('bulkOperationsPanel'); - this.isStripVisible = false; - - this.stripMaxThumbnails = 50; + // Remove bulk panel references since we're using context menu now + this.bulkContextMenu = null; // Will be set by core initialization // Model type specific action configurations this.actionConfig = { @@ -46,41 +42,13 @@ export class BulkManager { this.setupGlobalKeyboardListeners(); } + setBulkContextMenu(bulkContextMenu) { + this.bulkContextMenu = bulkContextMenu; + } + setupEventListeners() { - // Bulk operations button listeners - const sendToWorkflowBtn = this.bulkPanel?.querySelector('[data-action="send-to-workflow"]'); - const copyAllBtn = this.bulkPanel?.querySelector('[data-action="copy-all"]'); - const refreshAllBtn = this.bulkPanel?.querySelector('[data-action="refresh-all"]'); - const moveAllBtn = this.bulkPanel?.querySelector('[data-action="move-all"]'); - const deleteAllBtn = this.bulkPanel?.querySelector('[data-action="delete-all"]'); - const clearBtn = this.bulkPanel?.querySelector('[data-action="clear"]'); - - if (sendToWorkflowBtn) { - sendToWorkflowBtn.addEventListener('click', () => this.sendAllModelsToWorkflow()); - } - if (copyAllBtn) { - copyAllBtn.addEventListener('click', () => this.copyAllModelsSyntax()); - } - if (refreshAllBtn) { - refreshAllBtn.addEventListener('click', () => this.refreshAllMetadata()); - } - if (moveAllBtn) { - moveAllBtn.addEventListener('click', () => { - moveManager.showMoveModal('bulk'); - }); - } - if (deleteAllBtn) { - deleteAllBtn.addEventListener('click', () => this.showBulkDeleteModal()); - } - if (clearBtn) { - clearBtn.addEventListener('click', () => this.clearSelection()); - } - - // Selected count click listener - const selectedCount = document.getElementById('selectedCount'); - if (selectedCount) { - selectedCount.addEventListener('click', () => this.toggleThumbnailStrip()); - } + // Only setup bulk mode toggle button listener now + // Context menu actions are handled by BulkContextMenu } setupGlobalKeyboardListeners() { @@ -115,60 +83,15 @@ export class BulkManager { this.bulkBtn.classList.toggle('active', state.bulkMode); - if (state.bulkMode) { - this.bulkPanel.classList.remove('hidden'); - this.updateActionButtonsVisibility(); - setTimeout(() => { - this.bulkPanel.classList.add('visible'); - }, 10); - } else { - this.bulkPanel.classList.remove('visible'); - setTimeout(() => { - this.bulkPanel.classList.add('hidden'); - }, 400); - this.hideThumbnailStrip(); - } - updateCardsForBulkMode(state.bulkMode); if (!state.bulkMode) { this.clearSelection(); - // TODO: - document.querySelectorAll('.model-card').forEach(card => { - const actions = card.querySelectorAll('.card-actions, .card-button'); - actions.forEach(action => action.style.display = 'flex'); - }); - } - } - - updateActionButtonsVisibility() { - const currentModelType = state.currentPageType; - const config = this.actionConfig[currentModelType]; - - if (!config) return; - - // Update button visibility based on model type - const sendToWorkflowBtn = this.bulkPanel?.querySelector('[data-action="send-to-workflow"]'); - const copyAllBtn = this.bulkPanel?.querySelector('[data-action="copy-all"]'); - const refreshAllBtn = this.bulkPanel?.querySelector('[data-action="refresh-all"]'); - const moveAllBtn = this.bulkPanel?.querySelector('[data-action="move-all"]'); - const deleteAllBtn = this.bulkPanel?.querySelector('[data-action="delete-all"]'); - - if (sendToWorkflowBtn) { - sendToWorkflowBtn.style.display = config.sendToWorkflow ? 'block' : 'none'; - } - if (copyAllBtn) { - copyAllBtn.style.display = config.copyAll ? 'block' : 'none'; - } - if (refreshAllBtn) { - refreshAllBtn.style.display = config.refreshAll ? 'block' : 'none'; - } - if (moveAllBtn) { - moveAllBtn.style.display = config.moveAll ? 'block' : 'none'; - } - if (deleteAllBtn) { - deleteAllBtn.style.display = config.deleteAll ? 'block' : 'none'; + // Hide context menu when exiting bulk mode + if (this.bulkContextMenu) { + this.bulkContextMenu.hideMenu(); + } } } @@ -177,27 +100,10 @@ export class BulkManager { card.classList.remove('selected'); }); state.selectedModels.clear(); - this.updateSelectedCount(); - this.hideThumbnailStrip(); - } - - updateSelectedCount() { - const countElement = document.getElementById('selectedCount'); - if (countElement) { - // Use i18nHelpers.js to update the count text - updateElementText(countElement, 'loras.bulkOperations.selected', { count: state.selectedModels.size }); - - const existingCaret = countElement.querySelector('.dropdown-caret'); - if (existingCaret) { - existingCaret.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`; - existingCaret.style.visibility = state.selectedModels.size > 0 ? 'visible' : 'hidden'; - } else { - const caretIcon = document.createElement('i'); - caretIcon.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`; - caretIcon.style.visibility = state.selectedModels.size > 0 ? 'visible' : 'hidden'; - countElement.appendChild(caretIcon); - } + // Update context menu header if visible + if (this.bulkContextMenu) { + this.bulkContextMenu.updateSelectedCountHeader(); } } @@ -222,10 +128,9 @@ export class BulkManager { }); } - this.updateSelectedCount(); - - if (this.isStripVisible) { - this.updateThumbnailStrip(); + // Update context menu header if visible + if (this.bulkContextMenu) { + this.bulkContextMenu.updateSelectedCountHeader(); } } @@ -277,8 +182,6 @@ export class BulkManager { card.classList.remove('selected'); } }); - - this.updateSelectedCount(); } async copyAllModelsSyntax() { @@ -413,115 +316,6 @@ export class BulkManager { showToast('toast.models.deleteFailedGeneral', {}, 'error'); } } - - toggleThumbnailStrip() { - if (state.selectedModels.size === 0) return; - - const existing = document.querySelector('.selected-thumbnails-strip'); - if (existing) { - this.hideThumbnailStrip(); - } else { - this.showThumbnailStrip(); - } - } - - showThumbnailStrip() { - const strip = document.createElement('div'); - strip.className = 'selected-thumbnails-strip'; - - const thumbnailContainer = document.createElement('div'); - thumbnailContainer.className = 'thumbnails-container'; - strip.appendChild(thumbnailContainer); - - this.bulkPanel.parentNode.insertBefore(strip, this.bulkPanel); - - this.updateThumbnailStrip(); - - this.isStripVisible = true; - this.updateSelectedCount(); - - setTimeout(() => strip.classList.add('visible'), 10); - } - - hideThumbnailStrip() { - const strip = document.querySelector('.selected-thumbnails-strip'); - if (strip && this.isStripVisible) { - strip.classList.remove('visible'); - - this.isStripVisible = false; - - const countElement = document.getElementById('selectedCount'); - if (countElement) { - const caret = countElement.querySelector('.dropdown-caret'); - if (caret) { - caret.className = 'fas fa-caret-up dropdown-caret'; - } - } - - setTimeout(() => { - if (strip.parentNode) { - strip.parentNode.removeChild(strip); - } - }, 300); - } - } - - updateThumbnailStrip() { - const container = document.querySelector('.thumbnails-container'); - if (!container) return; - - container.innerHTML = ''; - - const selectedModels = Array.from(state.selectedModels); - - if (selectedModels.length > this.stripMaxThumbnails) { - const counter = document.createElement('div'); - counter.className = 'strip-counter'; - counter.textContent = `Showing ${this.stripMaxThumbnails} of ${selectedModels.length} selected`; - container.appendChild(counter); - } - - const thumbnailsToShow = selectedModels.slice(0, this.stripMaxThumbnails); - const metadataCache = this.getMetadataCache(); - - thumbnailsToShow.forEach(filepath => { - const metadata = metadataCache.get(filepath); - if (!metadata) return; - - const thumbnail = document.createElement('div'); - thumbnail.className = 'selected-thumbnail'; - thumbnail.dataset.filepath = filepath; - - if (metadata.isVideo) { - thumbnail.innerHTML = ` - - ${metadata.modelName} - - `; - } else { - thumbnail.innerHTML = ` - ${metadata.modelName} - ${metadata.modelName} - - `; - } - - thumbnail.addEventListener('click', (e) => { - if (!e.target.closest('.thumbnail-remove')) { - this.deselectItem(filepath); - } - }); - - thumbnail.querySelector('.thumbnail-remove').addEventListener('click', (e) => { - e.stopPropagation(); - this.deselectItem(filepath); - }); - - container.appendChild(thumbnail); - }); - } deselectItem(filepath) { const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`); @@ -530,13 +324,6 @@ export class BulkManager { } state.selectedModels.delete(filepath); - - this.updateSelectedCount(); - this.updateThumbnailStrip(); - - if (state.selectedModels.size === 0) { - this.hideThumbnailStrip(); - } } selectAllVisibleModels() { diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index ff488290..b9cb211a 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -44,6 +44,34 @@ +
+
+ + {{ t('loras.bulkOperations.selected', {'count': 0}) }} +
+
+
+ {{ t('loras.bulkOperations.sendToWorkflow') }} +
+
+ {{ t('loras.bulkOperations.copyAll') }} +
+
+ {{ t('loras.bulkOperations.refreshAll') }} +
+
+ {{ t('loras.bulkOperations.moveAll') }} +
+
+
+ {{ t('loras.bulkOperations.deleteAll') }} +
+
+
+ {{ t('loras.bulkOperations.clear') }} +
+
+

{{ t('modals.contentRating.title') }}

diff --git a/templates/components/controls.html b/templates/components/controls.html index dc1e1bf5..8c9f5af5 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -98,33 +98,4 @@
-
- - - \ No newline at end of file diff --git a/templates/loras.html b/templates/loras.html index b158632f..3ece6a10 100644 --- a/templates/loras.html +++ b/templates/loras.html @@ -16,8 +16,6 @@
- - {% endblock %} {% block overlay %} From c0b029e2283efd2749cd3f8adc9c9dd5fb3ed2c1 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Thu, 4 Sep 2025 16:34:05 +0800 Subject: [PATCH 2/9] feat(context-menu): refactor context menu initialization and coordination for improved bulk operations --- static/js/checkpoints.js | 8 +-- .../components/ContextMenu/BaseContextMenu.js | 11 ---- .../components/ContextMenu/BulkContextMenu.js | 36 +------------ static/js/components/ContextMenu/index.js | 54 ++++++++++++++++++- static/js/core.js | 19 ++++++- static/js/embeddings.js | 6 +-- static/js/loras.js | 6 +-- static/js/state/index.js | 2 + 8 files changed, 78 insertions(+), 64 deletions(-) diff --git a/static/js/checkpoints.js b/static/js/checkpoints.js index 907e92a6..a26099d3 100644 --- a/static/js/checkpoints.js +++ b/static/js/checkpoints.js @@ -1,7 +1,6 @@ import { appCore } from './core.js'; import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js'; import { createPageControls } from './components/controls/index.js'; -import { CheckpointContextMenu } from './components/ContextMenu/index.js'; import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; import { MODEL_TYPES } from './api/apiConfig.js'; @@ -30,10 +29,7 @@ class CheckpointsPageManager { } async initialize() { - // Initialize context menu - new CheckpointContextMenu(); - - // Initialize common page features + // Initialize common page features (including context menus) appCore.initializePageFeatures(); console.log('Checkpoints Manager initialized'); @@ -48,4 +44,4 @@ document.addEventListener('DOMContentLoaded', async () => { // Initialize checkpoints page const checkpointsPage = new CheckpointsPageManager(); await checkpointsPage.initialize(); -}); +}); \ No newline at end of file diff --git a/static/js/components/ContextMenu/BaseContextMenu.js b/static/js/components/ContextMenu/BaseContextMenu.js index e2f9edfc..8ec2d9eb 100644 --- a/static/js/components/ContextMenu/BaseContextMenu.js +++ b/static/js/components/ContextMenu/BaseContextMenu.js @@ -15,17 +15,6 @@ export class BaseContextMenu { init() { // Hide menu on regular clicks document.addEventListener('click', () => this.hideMenu()); - - // Show menu on right-click on cards - document.addEventListener('contextmenu', (e) => { - const card = e.target.closest(this.cardSelector); - if (!card) { - this.hideMenu(); - return; - } - e.preventDefault(); - this.showMenu(e.clientX, e.clientY, card); - }); // Handle menu item clicks this.menu.addEventListener('click', (e) => { diff --git a/static/js/components/ContextMenu/BulkContextMenu.js b/static/js/components/ContextMenu/BulkContextMenu.js index ef206b77..7e228641 100644 --- a/static/js/components/ContextMenu/BulkContextMenu.js +++ b/static/js/components/ContextMenu/BulkContextMenu.js @@ -1,8 +1,7 @@ import { BaseContextMenu } from './BaseContextMenu.js'; import { state } from '../../state/index.js'; import { bulkManager } from '../../managers/BulkManager.js'; -import { translate, updateElementText } from '../../utils/i18nHelpers.js'; -import { MODEL_TYPES } from '../../api/apiConfig.js'; +import { updateElementText } from '../../utils/i18nHelpers.js'; export class BulkContextMenu extends BaseContextMenu { constructor() { @@ -10,39 +9,6 @@ export class BulkContextMenu extends BaseContextMenu { this.setupBulkMenuItems(); } - init() { - // Override parent init to handle bulk-specific context menu logic - document.addEventListener('click', () => this.hideMenu()); - - document.addEventListener('contextmenu', (e) => { - const card = e.target.closest('.model-card'); - if (!card || !state.bulkMode) { - this.hideMenu(); - return; - } - - // Show bulk menu only if right-clicking on a selected card - if (card.classList.contains('selected')) { - e.preventDefault(); - this.showMenu(e.clientX, e.clientY, card); - } else { - this.hideMenu(); - } - }); - - // Handle menu item clicks - this.menu.addEventListener('click', (e) => { - const menuItem = e.target.closest('.context-menu-item'); - if (!menuItem || !this.currentCard) return; - - const action = menuItem.dataset.action; - if (!action) return; - - this.handleMenuAction(action, menuItem); - this.hideMenu(); - }); - } - setupBulkMenuItems() { if (!this.menu) return; diff --git a/static/js/components/ContextMenu/index.js b/static/js/components/ContextMenu/index.js index af539a53..306777ae 100644 --- a/static/js/components/ContextMenu/index.js +++ b/static/js/components/ContextMenu/index.js @@ -2,4 +2,56 @@ export { LoraContextMenu } from './LoraContextMenu.js'; export { RecipeContextMenu } from './RecipeContextMenu.js'; export { CheckpointContextMenu } from './CheckpointContextMenu.js'; export { EmbeddingContextMenu } from './EmbeddingContextMenu.js'; -export { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; \ No newline at end of file +export { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; + +import { LoraContextMenu } from './LoraContextMenu.js'; +import { RecipeContextMenu } from './RecipeContextMenu.js'; +import { CheckpointContextMenu } from './CheckpointContextMenu.js'; +import { EmbeddingContextMenu } from './EmbeddingContextMenu.js'; +import { state } from '../../state/index.js'; + +// Factory method to create page-specific context menu instances +export function createPageContextMenu(pageType) { + switch (pageType) { + case 'loras': + return new LoraContextMenu(); + case 'recipes': + return new RecipeContextMenu(); + case 'checkpoints': + return new CheckpointContextMenu(); + case 'embeddings': + return new EmbeddingContextMenu(); + default: + return null; + } +} + +// Initialize context menu coordination for pages that support it +export function initializeContextMenuCoordination(pageContextMenu, bulkContextMenu) { + // Centralized context menu event handler + document.addEventListener('contextmenu', (e) => { + const card = e.target.closest('.model-card'); + if (!card) { + // Hide all menus if not right-clicking on a card + pageContextMenu?.hideMenu(); + bulkContextMenu?.hideMenu(); + return; + } + + e.preventDefault(); + + // Hide all menus first + pageContextMenu?.hideMenu(); + bulkContextMenu?.hideMenu(); + + // Determine which menu to show based on bulk mode and selection state + if (state.bulkMode && card.classList.contains('selected')) { + // Show bulk menu for selected cards in bulk mode + bulkContextMenu?.showMenu(e.clientX, e.clientY, card); + } else if (!state.bulkMode) { + // Show regular menu when not in bulk mode + pageContextMenu?.showMenu(e.clientX, e.clientY, card); + } + // Don't show any menu for unselected cards in bulk mode + }); +} \ No newline at end of file diff --git a/static/js/core.js b/static/js/core.js index 21839d97..eb7d8dba 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -16,11 +16,14 @@ import { migrateStorageItems } from './utils/storageHelpers.js'; import { i18n } from './i18n/index.js'; import { onboardingManager } from './managers/OnboardingManager.js'; import { BulkContextMenu } from './components/ContextMenu/BulkContextMenu.js'; +import { createPageContextMenu, initializeContextMenuCoordination } from './components/ContextMenu/index.js'; // Core application class export class AppCore { constructor() { this.initialized = false; + this.pageContextMenu = null; + this.bulkContextMenu = null; } // Initialize core functionality @@ -93,13 +96,27 @@ export class AppCore { initializePageFeatures() { const pageType = this.getPageType(); - // Initialize virtual scroll for pages that need it if (['loras', 'recipes', 'checkpoints', 'embeddings'].includes(pageType)) { + this.initializeContextMenus(pageType); initializeInfiniteScroll(pageType); } return this; } + + // Initialize context menus for the current page + initializeContextMenus(pageType) { + // Create page-specific context menu + this.pageContextMenu = createPageContextMenu(pageType); + + // Get bulk context menu from bulkManager + this.bulkContextMenu = bulkManager.bulkContextMenu; + + // Initialize context menu coordination + if (this.pageContextMenu || this.bulkContextMenu) { + initializeContextMenuCoordination(this.pageContextMenu, this.bulkContextMenu); + } + } } document.addEventListener('DOMContentLoaded', () => { diff --git a/static/js/embeddings.js b/static/js/embeddings.js index c2276ce8..1352471c 100644 --- a/static/js/embeddings.js +++ b/static/js/embeddings.js @@ -1,7 +1,6 @@ import { appCore } from './core.js'; import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js'; import { createPageControls } from './components/controls/index.js'; -import { EmbeddingContextMenu } from './components/ContextMenu/index.js'; import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; import { MODEL_TYPES } from './api/apiConfig.js'; @@ -30,10 +29,7 @@ class EmbeddingsPageManager { } async initialize() { - // Initialize context menu - new EmbeddingContextMenu(); - - // Initialize common page features + // Initialize common page features (including context menus) appCore.initializePageFeatures(); console.log('Embeddings Manager initialized'); diff --git a/static/js/loras.js b/static/js/loras.js index d8820cac..6566235f 100644 --- a/static/js/loras.js +++ b/static/js/loras.js @@ -1,7 +1,6 @@ import { appCore } from './core.js'; import { state } from './state/index.js'; import { updateCardsForBulkMode } from './components/shared/ModelCard.js'; -import { LoraContextMenu } from './components/ContextMenu/index.js'; import { createPageControls } from './components/controls/index.js'; import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js'; import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; @@ -37,13 +36,10 @@ class LoraPageManager { } async initialize() { - // Initialize page-specific components - new LoraContextMenu(); - // Initialize cards for current bulk mode state (should be false initially) updateCardsForBulkMode(state.bulkMode); - // Initialize common page features (virtual scroll) + // Initialize common page features (including context menus and virtual scroll) appCore.initializePageFeatures(); } } diff --git a/static/js/state/index.js b/static/js/state/index.js index f905eb09..65d5619f 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -71,6 +71,8 @@ export const state = { pageSize: 20, showFavoritesOnly: false, duplicatesMode: false, + bulkMode: false, + selectedModels: new Set(), }, [MODEL_TYPES.CHECKPOINT]: { From a5a9f7ed8391b0f92c701e3ffe022421e5449b01 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Thu, 4 Sep 2025 22:07:07 +0800 Subject: [PATCH 3/9] fix(banner): ensure href attribute defaults to '#' for actions without a URL --- static/js/managers/BannerService.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/js/managers/BannerService.js b/static/js/managers/BannerService.js index 808bfb12..2793198d 100644 --- a/static/js/managers/BannerService.js +++ b/static/js/managers/BannerService.js @@ -136,10 +136,10 @@ class BannerService { const actionsHtml = banner.actions ? banner.actions.map(action => { const actionAttribute = action.action ? `data-action="${action.action}"` : ''; - const href = action.url ? `href="${action.url}"` : '#'; + const href = action.url ? `href="${action.url}"` : 'href="#"'; const target = action.url ? 'target="_blank" rel="noopener noreferrer"' : ''; - return ``; From 4eb67cf6dadd7a8acf4782ae0486ca1911301ffb Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Thu, 4 Sep 2025 22:08:55 +0800 Subject: [PATCH 4/9] feat(bulk-tags): add bulk tag management modal and context menu integration --- .../components/ContextMenu/BulkContextMenu.js | 7 + static/js/components/shared/ModelTags.js | 2 +- static/js/managers/BulkManager.js | 222 ++++++++++++++++++ static/js/managers/ModalManager.js | 13 + templates/components/context_menu.html | 3 + templates/components/modals.html | 3 +- .../modals/bulk_add_tags_modal.html | 37 +++ 7 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 templates/components/modals/bulk_add_tags_modal.html diff --git a/static/js/components/ContextMenu/BulkContextMenu.js b/static/js/components/ContextMenu/BulkContextMenu.js index 7e228641..70030764 100644 --- a/static/js/components/ContextMenu/BulkContextMenu.js +++ b/static/js/components/ContextMenu/BulkContextMenu.js @@ -26,6 +26,7 @@ export class BulkContextMenu extends BaseContextMenu { if (!config) return; // Update button visibility based on model type + const addTagsItem = this.menu.querySelector('[data-action="add-tags"]'); const sendToWorkflowItem = this.menu.querySelector('[data-action="send-to-workflow"]'); const copyAllItem = this.menu.querySelector('[data-action="copy-all"]'); const refreshAllItem = this.menu.querySelector('[data-action="refresh-all"]'); @@ -47,6 +48,9 @@ export class BulkContextMenu extends BaseContextMenu { if (deleteAllItem) { deleteAllItem.style.display = config.deleteAll ? 'flex' : 'none'; } + if (addTagsItem) { + addTagsItem.style.display = config.addTags ? 'flex' : 'none'; + } } updateSelectedCountHeader() { @@ -64,6 +68,9 @@ export class BulkContextMenu extends BaseContextMenu { handleMenuAction(action, menuItem) { switch (action) { + case 'add-tags': + bulkManager.showBulkAddTagsModal(); + break; case 'send-to-workflow': bulkManager.sendAllModelsToWorkflow(); break; diff --git a/static/js/components/shared/ModelTags.js b/static/js/components/shared/ModelTags.js index 87abe0ce..ca958d6a 100644 --- a/static/js/components/shared/ModelTags.js +++ b/static/js/components/shared/ModelTags.js @@ -139,7 +139,7 @@ export function setupTagEditMode() { // ...existing helper functions... /** - * Save tags - 支持LoRA和Checkpoint + * Save tags */ async function saveTags() { const editBtn = document.querySelector('.edit-tags-btn'); diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index 8946bc11..052163c7 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -14,6 +14,7 @@ export class BulkManager { // Model type specific action configurations this.actionConfig = { [MODEL_TYPES.LORA]: { + addTags: true, sendToWorkflow: true, copyAll: true, refreshAll: true, @@ -21,6 +22,7 @@ export class BulkManager { deleteAll: true }, [MODEL_TYPES.EMBEDDING]: { + addTags: true, sendToWorkflow: false, copyAll: false, refreshAll: true, @@ -28,6 +30,7 @@ export class BulkManager { deleteAll: true }, [MODEL_TYPES.CHECKPOINT]: { + addTags: true, sendToWorkflow: false, copyAll: false, refreshAll: true, @@ -406,6 +409,225 @@ export class BulkManager { showToast('toast.models.refreshMetadataFailed', {}, 'error'); } } + + showBulkAddTagsModal() { + if (state.selectedModels.size === 0) { + showToast('toast.models.noModelsSelected', {}, 'warning'); + return; + } + + const countElement = document.getElementById('bulkAddTagsCount'); + if (countElement) { + countElement.textContent = state.selectedModels.size; + } + + // Clear any existing tags in the modal + const tagsContainer = document.getElementById('bulkTagsItems'); + if (tagsContainer) { + tagsContainer.innerHTML = ''; + } + + modalManager.showModal('bulkAddTagsModal', null, null, () => { + // Cleanup when modal is closed + this.cleanupBulkAddTagsModal(); + }); + + // Initialize the bulk tags editing interface + this.initializeBulkTagsInterface(); + } + + initializeBulkTagsInterface() { + // Import preset tags from ModelTags.js + const PRESET_TAGS = [ + 'character', 'style', 'concept', 'clothing', + 'poses', 'background', 'vehicle', 'buildings', + 'objects', 'animal' + ]; + + // Setup tag input behavior + const tagInput = document.querySelector('.bulk-metadata-input'); + if (tagInput) { + tagInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + this.addBulkTag(e.target.value.trim()); + e.target.value = ''; + } + }); + } + + // Create suggestions dropdown + const tagForm = document.querySelector('#bulkAddTagsModal .metadata-add-form'); + if (tagForm) { + const suggestionsDropdown = this.createBulkSuggestionsDropdown(PRESET_TAGS); + tagForm.appendChild(suggestionsDropdown); + } + + // Setup save button + const saveBtn = document.querySelector('.bulk-save-tags-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', () => { + this.saveBulkTags(); + }); + } + } + + createBulkSuggestionsDropdown(presetTags) { + const dropdown = document.createElement('div'); + dropdown.className = 'metadata-suggestions-dropdown'; + + const header = document.createElement('div'); + header.className = 'metadata-suggestions-header'; + header.innerHTML = ` + Suggested Tags + Click to add + `; + dropdown.appendChild(header); + + const container = document.createElement('div'); + container.className = 'metadata-suggestions-container'; + + presetTags.forEach(tag => { + const item = document.createElement('div'); + item.className = 'metadata-suggestion-item'; + item.title = tag; + item.innerHTML = `${tag}`; + + item.addEventListener('click', () => { + this.addBulkTag(tag); + const input = document.querySelector('.bulk-metadata-input'); + if (input) { + input.value = tag; + input.focus(); + } + }); + + container.appendChild(item); + }); + + dropdown.appendChild(container); + return dropdown; + } + + addBulkTag(tag) { + tag = tag.trim().toLowerCase(); + if (!tag) return; + + const tagsContainer = document.getElementById('bulkTagsItems'); + if (!tagsContainer) return; + + // Validation: Check length + if (tag.length > 30) { + showToast('modelTags.validation.maxLength', {}, 'error'); + return; + } + + // Validation: Check total number + const currentTags = tagsContainer.querySelectorAll('.metadata-item'); + if (currentTags.length >= 30) { + showToast('modelTags.validation.maxCount', {}, 'error'); + return; + } + + // Validation: Check for duplicates + const existingTags = Array.from(currentTags).map(tagEl => tagEl.dataset.tag); + if (existingTags.includes(tag)) { + showToast('modelTags.validation.duplicate', {}, 'error'); + return; + } + + // Create new tag + const newTag = document.createElement('div'); + newTag.className = 'metadata-item'; + newTag.dataset.tag = tag; + newTag.innerHTML = ` + ${tag} + + `; + + // Add delete button event listener + const deleteBtn = newTag.querySelector('.metadata-delete-btn'); + deleteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + newTag.remove(); + }); + + tagsContainer.appendChild(newTag); + } + + async saveBulkTags() { + const tagElements = document.querySelectorAll('#bulkTagsItems .metadata-item'); + const tags = Array.from(tagElements).map(tag => tag.dataset.tag); + + if (tags.length === 0) { + showToast('toast.models.noTagsToAdd', {}, 'warning'); + return; + } + + if (state.selectedModels.size === 0) { + showToast('toast.models.noModelsSelected', {}, 'warning'); + return; + } + + try { + const apiClient = getModelApiClient(); + const filePaths = Array.from(state.selectedModels); + let successCount = 0; + let failCount = 0; + + // Add tags to each selected model + for (const filePath of filePaths) { + try { + await apiClient.addTags(filePath, { tags: tags }); + successCount++; + } catch (error) { + console.error(`Failed to add tags to ${filePath}:`, error); + failCount++; + } + } + + modalManager.closeModal('bulkAddTagsModal'); + + if (successCount > 0) { + const currentConfig = MODEL_CONFIG[state.currentPageType]; + showToast('toast.models.tagsAddedSuccessfully', { + count: successCount, + tagCount: tags.length, + type: currentConfig.displayName.toLowerCase() + }, 'success'); + } + + if (failCount > 0) { + showToast('toast.models.tagsAddFailed', { count: failCount }, 'warning'); + } + + } catch (error) { + console.error('Error during bulk tag addition:', error); + showToast('toast.models.bulkTagsAddFailed', {}, 'error'); + } + } + + cleanupBulkAddTagsModal() { + // Clear tags container + const tagsContainer = document.getElementById('bulkTagsItems'); + if (tagsContainer) { + tagsContainer.innerHTML = ''; + } + + // Clear input + const input = document.querySelector('.bulk-metadata-input'); + if (input) { + input.value = ''; + } + + // Remove event listeners (they will be re-added when modal opens again) + const saveBtn = document.querySelector('.bulk-save-tags-btn'); + if (saveBtn) { + saveBtn.replaceWith(saveBtn.cloneNode(true)); + } + } } export const bulkManager = new BulkManager(); diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index 9f56c269..8313c226 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -234,6 +234,19 @@ export class ModalManager { }); } + // Add bulkAddTagsModal registration + const bulkAddTagsModal = document.getElementById('bulkAddTagsModal'); + if (bulkAddTagsModal) { + this.registerModal('bulkAddTagsModal', { + element: bulkAddTagsModal, + onClose: () => { + this.getModal('bulkAddTagsModal').element.style.display = 'none'; + document.body.classList.remove('modal-open'); + }, + closeOnOutsideClick: true + }); + } + document.addEventListener('keydown', this.boundHandleEscape); this.initialized = true; } diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index b9cb211a..92e7a5b2 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -50,6 +50,9 @@ {{ t('loras.bulkOperations.selected', {'count': 0}) }}
+
+ {{ t('loras.bulkOperations.addTags') }} +
{{ t('loras.bulkOperations.sendToWorkflow') }}
diff --git a/templates/components/modals.html b/templates/components/modals.html index 0b003976..14068b44 100644 --- a/templates/components/modals.html +++ b/templates/components/modals.html @@ -9,4 +9,5 @@ {% include 'components/modals/relink_civitai_modal.html' %} {% include 'components/modals/example_access_modal.html' %} {% include 'components/modals/download_modal.html' %} -{% include 'components/modals/move_modal.html' %} \ No newline at end of file +{% include 'components/modals/move_modal.html' %} +{% include 'components/modals/bulk_add_tags_modal.html' %} \ No newline at end of file diff --git a/templates/components/modals/bulk_add_tags_modal.html b/templates/components/modals/bulk_add_tags_modal.html new file mode 100644 index 00000000..7538ec89 --- /dev/null +++ b/templates/components/modals/bulk_add_tags_modal.html @@ -0,0 +1,37 @@ + From 1d31dae11077b8f688a95e41a7305ec02a0762aa Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Fri, 5 Sep 2025 07:18:24 +0800 Subject: [PATCH 5/9] feat(tags): implement bulk tag addition and replacement functionality --- locales/en.json | 20 +++++- py/routes/base_model_routes.py | 5 ++ py/utils/routes_common.py | 66 ++++++++++++++++++- static/css/components/modal/_base.css | 4 ++ .../css/components/shared/edit-metadata.css | 30 ++++++++- static/js/api/apiConfig.js | 3 + static/js/api/baseModelApi.js | 28 ++++++++ static/js/managers/BulkManager.js | 50 +++++++++----- .../modals/bulk_add_tags_modal.html | 15 +++-- 9 files changed, 193 insertions(+), 28 deletions(-) diff --git a/locales/en.json b/locales/en.json index c518574a..32656961 100644 --- a/locales/en.json +++ b/locales/en.json @@ -319,6 +319,7 @@ "selected": "{count} selected", "selectedSuffix": "selected", "viewSelected": "Click to view selected items", + "addTags": "Add Tags", "sendToWorkflow": "Send to Workflow", "copyAll": "Copy All", "refreshAll": "Refresh All", @@ -572,6 +573,16 @@ "countMessage": "models will be permanently deleted.", "action": "Delete All" }, + "bulkAddTags": { + "title": "Add Tags to Multiple Models", + "description": "Add tags to", + "models": "models", + "tagsToAdd": "Tags to Add", + "placeholder": "Enter tag and press Enter...", + "appendTags": "Append Tags", + "replaceTags": "Replace Tags", + "saveChanges": "Save changes" + }, "exampleAccess": { "title": "Local Example Images", "message": "No local example images found for this model. View options:", @@ -987,7 +998,14 @@ "verificationAlreadyDone": "This group has already been verified", "verificationCompleteMismatch": "Verification complete. {count} file(s) have different actual hashes.", "verificationCompleteSuccess": "Verification complete. All files are confirmed duplicates.", - "verificationFailed": "Failed to verify hashes: {message}" + "verificationFailed": "Failed to verify hashes: {message}", + "noTagsToAdd": "No tags to add", + "tagsAddedSuccessfully": "Successfully added {tagCount} tag(s) to {count} {type}(s)", + "tagsReplacedSuccessfully": "Successfully replaced tags for {count} {type}(s) with {tagCount} tag(s)", + "tagsAddFailed": "Failed to add tags to {count} model(s)", + "tagsReplaceFailed": "Failed to replace tags for {count} model(s)", + "bulkTagsAddFailed": "Failed to add tags to models", + "bulkTagsReplaceFailed": "Failed to replace tags for models" }, "search": { "atLeastOneOption": "At least one search option must be selected" diff --git a/py/routes/base_model_routes.py b/py/routes/base_model_routes.py index bf77a9b2..81f8edcd 100644 --- a/py/routes/base_model_routes.py +++ b/py/routes/base_model_routes.py @@ -49,6 +49,7 @@ class BaseModelRoutes(ABC): app.router.add_post(f'/api/{prefix}/relink-civitai', self.relink_civitai) app.router.add_post(f'/api/{prefix}/replace-preview', self.replace_preview) app.router.add_post(f'/api/{prefix}/save-metadata', self.save_metadata) + app.router.add_post(f'/api/{prefix}/add-tags', self.add_tags) app.router.add_post(f'/api/{prefix}/rename', self.rename_model) app.router.add_post(f'/api/{prefix}/bulk-delete', self.bulk_delete_models) app.router.add_post(f'/api/{prefix}/verify-duplicates', self.verify_duplicates) @@ -272,6 +273,10 @@ class BaseModelRoutes(ABC): """Handle saving metadata updates""" return await ModelRouteUtils.handle_save_metadata(request, self.service.scanner) + async def add_tags(self, request: web.Request) -> web.Response: + """Handle adding tags to model metadata""" + return await ModelRouteUtils.handle_add_tags(request, self.service.scanner) + async def rename_model(self, request: web.Request) -> web.Response: """Handle renaming a model file and its associated files""" return await ModelRouteUtils.handle_rename_model(request, self.service.scanner) diff --git a/py/utils/routes_common.py b/py/utils/routes_common.py index 3217cf2f..8f9cfe7f 100644 --- a/py/utils/routes_common.py +++ b/py/utils/routes_common.py @@ -870,11 +870,11 @@ class ModelRouteUtils: metadata = await ModelRouteUtils.load_local_metadata(metadata_path) # Compare hashes - stored_hash = metadata.get('sha256', '').lower() + stored_hash = metadata.get('sha256', '').lower(); # Set expected hash from first file if not yet set if not expected_hash: - expected_hash = stored_hash + expected_hash = stored_hash; # Check if hash matches expected hash if actual_hash != expected_hash: @@ -978,7 +978,7 @@ class ModelRouteUtils: if os.path.exists(metadata_path): metadata = await ModelRouteUtils.load_local_metadata(metadata_path) hash_value = metadata.get('sha256') - + logger.info(f"hash_value: {hash_value}, metadata_path: {metadata_path}, metadata: {metadata}") # Rename all files renamed_files = [] new_metadata_path = None @@ -1093,3 +1093,63 @@ class ModelRouteUtils: except Exception as e: logger.error(f"Error saving metadata: {e}", exc_info=True) return web.Response(text=str(e), status=500) + + @staticmethod + async def handle_add_tags(request: web.Request, scanner) -> web.Response: + """Handle adding tags to model metadata + + Args: + request: The aiohttp request + scanner: The model scanner instance + + Returns: + web.Response: The HTTP response + """ + try: + data = await request.json() + file_path = data.get('file_path') + new_tags = data.get('tags', []) + + if not file_path: + return web.Response(text='File path is required', status=400) + + if not isinstance(new_tags, list): + return web.Response(text='Tags must be a list', status=400) + + # Get metadata file path + metadata_path = os.path.splitext(file_path)[0] + '.metadata.json' + + # Load existing metadata + metadata = await ModelRouteUtils.load_local_metadata(metadata_path) + + # Get existing tags (case insensitive) + existing_tags = metadata.get('tags', []) + existing_tags_lower = [tag.lower() for tag in existing_tags] + + # Add new tags that don't already exist (case insensitive check) + tags_added = [] + for tag in new_tags: + if isinstance(tag, str) and tag.strip(): + tag_stripped = tag.strip() + if tag_stripped.lower() not in existing_tags_lower: + existing_tags.append(tag_stripped) + existing_tags_lower.append(tag_stripped.lower()) + tags_added.append(tag_stripped) + + # Update metadata with combined tags + metadata['tags'] = existing_tags + + # Save updated metadata + await MetadataManager.save_metadata(file_path, metadata) + + # Update cache + await scanner.update_single_model_cache(file_path, file_path, metadata) + + return web.json_response({ + 'success': True, + 'tags': existing_tags + }) + + except Exception as e: + logger.error(f"Error adding tags: {e}", exc_info=True) + return web.Response(text=str(e), status=500) diff --git a/static/css/components/modal/_base.css b/static/css/components/modal/_base.css index 71172e6d..2b1d542d 100644 --- a/static/css/components/modal/_base.css +++ b/static/css/components/modal/_base.css @@ -37,6 +37,10 @@ body.modal-open { overflow-x: hidden; /* 防止水平滚动条 */ } +.modal-content-large { + min-height: 480px; +} + /* 当 modal 打开时锁定 body */ body.modal-open { overflow: hidden !important; /* 覆盖 base.css 中的 scroll */ diff --git a/static/css/components/shared/edit-metadata.css b/static/css/components/shared/edit-metadata.css index cf14997b..67838642 100644 --- a/static/css/components/shared/edit-metadata.css +++ b/static/css/components/shared/edit-metadata.css @@ -80,6 +80,7 @@ align-items: flex-start; margin-bottom: var(--space-2); width: 100%; + min-height: 30px; /* Ensure some height even if empty to prevent layout shifts */ } /* Individual Item */ @@ -153,17 +154,42 @@ } .metadata-save-btn, -.save-tags-btn { +.save-tags-btn, +.append-tags-btn, +.replace-tags-btn { background: var(--lora-accent) !important; color: white !important; border-color: var(--lora-accent) !important; } .metadata-save-btn:hover, -.save-tags-btn:hover { +.save-tags-btn:hover, +.append-tags-btn:hover, +.replace-tags-btn:hover { opacity: 0.9; } +/* Specific styling for bulk tag action buttons */ +.bulk-append-tags-btn { + background: var(--lora-accent) !important; + color: white !important; + border-color: var(--lora-accent) !important; +} + +.bulk-replace-tags-btn { + background: var(--lora-warning, #f59e0b) !important; + color: white !important; + border-color: var(--lora-warning, #f59e0b) !important; +} + +.bulk-append-tags-btn:hover { + opacity: 0.9; +} + +.bulk-replace-tags-btn:hover { + background: var(--lora-warning-dark, #d97706) !important; +} + /* Add Form */ .metadata-add-form { display: flex; diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index 73638e20..44174119 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -63,6 +63,9 @@ export function getApiEndpoints(modelType) { // Bulk operations bulkDelete: `/api/${modelType}/bulk-delete`, + + // Tag operations + addTags: `/api/${modelType}/add-tags`, // Move operations (now common for all model types that support move) moveModel: `/api/${modelType}/move_model`, diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 46e98a08..485b4447 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -306,6 +306,34 @@ export class BaseModelApiClient { } } + async addTags(filePath, data) { + try { + const response = await fetch(this.apiConfig.endpoints.addTags, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file_path: filePath, + ...data + }) + }); + + if (!response.ok) { + throw new Error('Failed to add tags'); + } + + const result = await response.json(); + + if (result.success && result.tags) { + state.virtualScroller.updateSingleItem(filePath, { tags: result.tags }); + } + + return result; + } catch (error) { + console.error('Error adding tags:', error); + throw error; + } + } + async refreshModels(fullRebuild = false) { try { state.loadingManager.showSimpleLoading( diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index 052163c7..3b4116f2 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -464,10 +464,18 @@ export class BulkManager { } // Setup save button - const saveBtn = document.querySelector('.bulk-save-tags-btn'); - if (saveBtn) { - saveBtn.addEventListener('click', () => { - this.saveBulkTags(); + const appendBtn = document.querySelector('.bulk-append-tags-btn'); + const replaceBtn = document.querySelector('.bulk-replace-tags-btn'); + + if (appendBtn) { + appendBtn.addEventListener('click', () => { + this.saveBulkTags('append'); + }); + } + + if (replaceBtn) { + replaceBtn.addEventListener('click', () => { + this.saveBulkTags('replace'); }); } } @@ -557,7 +565,7 @@ export class BulkManager { tagsContainer.appendChild(newTag); } - async saveBulkTags() { + async saveBulkTags(mode = 'append') { const tagElements = document.querySelectorAll('#bulkTagsItems .metadata-item'); const tags = Array.from(tagElements).map(tag => tag.dataset.tag); @@ -577,13 +585,17 @@ export class BulkManager { let successCount = 0; let failCount = 0; - // Add tags to each selected model + // Add or replace tags for each selected model based on mode for (const filePath of filePaths) { try { - await apiClient.addTags(filePath, { tags: tags }); + if (mode === 'replace') { + await apiClient.saveModelMetadata(filePath, { tags: tags }); + } else { + await apiClient.addTags(filePath, { tags: tags }); + } successCount++; } catch (error) { - console.error(`Failed to add tags to ${filePath}:`, error); + console.error(`Failed to ${mode} tags for ${filePath}:`, error); failCount++; } } @@ -592,7 +604,8 @@ export class BulkManager { if (successCount > 0) { const currentConfig = MODEL_CONFIG[state.currentPageType]; - showToast('toast.models.tagsAddedSuccessfully', { + const toastKey = mode === 'replace' ? 'toast.models.tagsReplacedSuccessfully' : 'toast.models.tagsAddedSuccessfully'; + showToast(toastKey, { count: successCount, tagCount: tags.length, type: currentConfig.displayName.toLowerCase() @@ -600,12 +613,14 @@ export class BulkManager { } if (failCount > 0) { - showToast('toast.models.tagsAddFailed', { count: failCount }, 'warning'); + const toastKey = mode === 'replace' ? 'toast.models.tagsReplaceFailed' : 'toast.models.tagsAddFailed'; + showToast(toastKey, { count: failCount }, 'warning'); } } catch (error) { - console.error('Error during bulk tag addition:', error); - showToast('toast.models.bulkTagsAddFailed', {}, 'error'); + console.error('Error during bulk tag operation:', error); + const toastKey = mode === 'replace' ? 'toast.models.bulkTagsReplaceFailed' : 'toast.models.bulkTagsAddFailed'; + showToast(toastKey, {}, 'error'); } } @@ -623,9 +638,14 @@ export class BulkManager { } // Remove event listeners (they will be re-added when modal opens again) - const saveBtn = document.querySelector('.bulk-save-tags-btn'); - if (saveBtn) { - saveBtn.replaceWith(saveBtn.cloneNode(true)); + const appendBtn = document.querySelector('.bulk-append-tags-btn'); + if (appendBtn) { + appendBtn.replaceWith(appendBtn.cloneNode(true)); + } + + const replaceBtn = document.querySelector('.bulk-replace-tags-btn'); + if (replaceBtn) { + replaceBtn.replaceWith(replaceBtn.cloneNode(true)); } } } diff --git a/templates/components/modals/bulk_add_tags_modal.html b/templates/components/modals/bulk_add_tags_modal.html index 7538ec89..0ec7dcaf 100644 --- a/templates/components/modals/bulk_add_tags_modal.html +++ b/templates/components/modals/bulk_add_tags_modal.html @@ -1,10 +1,8 @@