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}
-
- `;
- }
-
- 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 @@
+