From 14aef237a945f3e832394d257df8f28efdd7987b Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Fri, 7 Mar 2025 15:36:07 +0800 Subject: [PATCH] Add interactive thumbnail strip for bulk LoRA selection --- static/css/components/bulk.css | 283 +++++++++++++++++++++++++++++ static/css/layout.css | 125 ------------- static/css/style.css | 1 + static/js/managers/BulkManager.js | 180 +++++++++++++++++- templates/components/controls.html | 4 +- 5 files changed, 464 insertions(+), 129 deletions(-) create mode 100644 static/css/components/bulk.css diff --git a/static/css/components/bulk.css b/static/css/components/bulk.css new file mode 100644 index 00000000..76f88397 --- /dev/null +++ b/static/css/components/bulk.css @@ -0,0 +1,283 @@ +/* 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; +} + +/* 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; +} + +/* Thumbnail Strip Styles */ +.selected-thumbnails-strip { + position: fixed; + bottom: 80px; /* Position above the bulk operations panel */ + left: 50%; + transform: translateX(-50%) translateY(20px); + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-base); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + z-index: calc(var(--z-overlay) - 1); /* Just below the bulk panel z-index */ + padding: 16px; + max-width: 80%; + width: auto; + transition: all 0.3s ease; + opacity: 0; + overflow: hidden; +} + +.selected-thumbnails-strip.visible { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +.thumbnails-container { + display: flex; + gap: 12px; + overflow-x: auto; + padding-bottom: 8px; /* Space for scrollbar */ + max-width: 100%; + align-items: flex-start; +} + +.selected-thumbnail { + position: relative; + width: 80px; + min-width: 80px; /* Prevent shrinking */ + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + overflow: hidden; + cursor: pointer; + background: var(--bg-color); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.selected-thumbnail:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.selected-thumbnail img, +.selected-thumbnail video { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + display: block; +} + +.thumbnail-name { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.6); + color: white; + font-size: 10px; + padding: 3px 5px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.thumbnail-remove { + position: absolute; + top: 3px; + right: 3px; + width: 18px; + height: 18px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.5); + color: white; + border: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 10px; + opacity: 0.7; + transition: opacity 0.2s ease, background-color 0.2s ease; +} + +.thumbnail-remove:hover { + opacity: 1; + background: var(--lora-error); +} + +.strip-close-btn { + position: absolute; + top: 5px; + right: 5px; + width: 20px; + height: 20px; + background: none; + border: none; + color: var(--text-color); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.7; + transition: opacity 0.2s ease; +} + +.strip-close-btn:hover { + opacity: 1; +} + +/* Style the selectedCount to indicate it's clickable */ +.selectable-count { + display: flex; + align-items: center; + gap: 5px; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.selectable-count:hover { + background: var(--lora-border); +} + +.dropdown-caret { + font-size: 12px; + visibility: hidden; /* Will be shown via JS when items are selected */ +} + +/* Scrollbar styling for the thumbnails container */ +.thumbnails-container::-webkit-scrollbar { + height: 6px; +} + +.thumbnails-container::-webkit-scrollbar-track { + background: var(--bg-color); + border-radius: 3px; +} + +.thumbnails-container::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; +} + +.thumbnails-container::-webkit-scrollbar-thumb:hover { + background: var(--lora-accent); +} + +/* Mobile optimizations */ +@media (max-width: 768px) { + .selected-thumbnails-strip { + width: calc(100% - 40px); + max-width: none; + left: 20px; + transform: translateY(20px); + border-radius: var(--border-radius-sm); + } + + .selected-thumbnails-strip.visible { + transform: translateY(0); + } + + .selected-thumbnail { + width: 70px; + min-width: 70px; + } +} \ No newline at end of file diff --git a/static/css/layout.css b/static/css/layout.css index f1331576..e4f8a74c 100644 --- a/static/css/layout.css +++ b/static/css/layout.css @@ -387,91 +387,6 @@ } } -/* 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; @@ -480,43 +395,3 @@ 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/css/style.css b/static/css/style.css index c81e5ada..fd27e5ba 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -15,6 +15,7 @@ @import 'components/lora-modal.css'; @import 'components/support-modal.css'; @import 'components/search-filter.css'; +@import 'components/bulk.css'; .initialization-notice { display: flex; diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index 58ab0beb..d51edeec 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -6,6 +6,7 @@ export class BulkManager { constructor() { this.bulkBtn = document.getElementById('bulkOperationsBtn'); this.bulkPanel = document.getElementById('bulkOperationsPanel'); + this.isStripVisible = false; // Track strip visibility state // Initialize selected loras set in state if not already there if (!state.selectedLoras) { @@ -21,6 +22,12 @@ export class BulkManager { initialize() { // Add event listeners if needed // (Already handled via onclick attributes in HTML, but could be moved here) + + // Add event listeners for the selected count to toggle thumbnail strip + const selectedCount = document.getElementById('selectedCount'); + if (selectedCount) { + selectedCount.addEventListener('click', () => this.toggleThumbnailStrip()); + } } toggleBulkMode() { @@ -44,6 +51,9 @@ export class BulkManager { setTimeout(() => { this.bulkPanel.classList.add('hidden'); }, 400); // Match this with the transition duration in CSS + + // Hide thumbnail strip if it's visible + this.hideThumbnailStrip(); } // Update all cards @@ -61,13 +71,29 @@ export class BulkManager { }); state.selectedLoras.clear(); this.updateSelectedCount(); + + // Hide thumbnail strip if it's visible + this.hideThumbnailStrip(); } updateSelectedCount() { const countElement = document.getElementById('selectedCount'); if (countElement) { - countElement.textContent = `${state.selectedLoras.size} selected`; + // Set text content without the icon + countElement.textContent = `${state.selectedLoras.size} selected `; + + // Re-add the caret icon with proper direction + const caretIcon = document.createElement('i'); + // Use down arrow if strip is visible, up arrow if not + caretIcon.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`; + caretIcon.style.visibility = state.selectedLoras.size > 0 ? 'visible' : 'hidden'; + countElement.appendChild(caretIcon); + + // If there are no selections, hide the thumbnail strip + if (state.selectedLoras.size === 0) { + this.hideThumbnailStrip(); + } } } @@ -84,11 +110,31 @@ export class BulkManager { // Cache the metadata for this lora state.loraMetadataCache.set(filepath, { fileName: card.dataset.file_name, - usageTips: card.dataset.usage_tips + usageTips: card.dataset.usage_tips, + previewUrl: this.getCardPreviewUrl(card), + isVideo: this.isCardPreviewVideo(card), + modelName: card.dataset.name }); } this.updateSelectedCount(); + + // Update thumbnail strip if it's visible + if (this.isStripVisible) { + this.updateThumbnailStrip(); + } + } + + // Helper method to get preview URL from a card + getCardPreviewUrl(card) { + const img = card.querySelector('img'); + const video = card.querySelector('video source'); + return img ? img.src : (video ? video.src : '/loras_static/images/no-preview.png'); + } + + // Helper method to check if preview is a video + isCardPreviewVideo(card) { + return card.querySelector('video') !== null; } // Apply selection state to cards after they are refreshed @@ -103,7 +149,10 @@ export class BulkManager { // Update the cache with latest data state.loraMetadataCache.set(filepath, { fileName: card.dataset.file_name, - usageTips: card.dataset.usage_tips + usageTips: card.dataset.usage_tips, + previewUrl: this.getCardPreviewUrl(card), + isVideo: this.isCardPreviewVideo(card), + modelName: card.dataset.name }); } else { card.classList.remove('selected'); @@ -155,6 +204,131 @@ export class BulkManager { showToast('Copy failed', 'error'); } } + + // Create and show the thumbnail strip of selected LoRAs + toggleThumbnailStrip() { + // If no items are selected, do nothing + if (state.selectedLoras.size === 0) return; + + const existing = document.querySelector('.selected-thumbnails-strip'); + if (existing) { + this.hideThumbnailStrip(); + } else { + this.showThumbnailStrip(); + } + } + + showThumbnailStrip() { + // Create the thumbnail strip container + const strip = document.createElement('div'); + strip.className = 'selected-thumbnails-strip'; + + // Create a container for the thumbnails (for scrolling) + const thumbnailContainer = document.createElement('div'); + thumbnailContainer.className = 'thumbnails-container'; + strip.appendChild(thumbnailContainer); + + // Position the strip above the bulk operations panel + this.bulkPanel.parentNode.insertBefore(strip, this.bulkPanel); + + // Populate the thumbnails + this.updateThumbnailStrip(); + + // Update strip visibility state and caret direction + this.isStripVisible = true; + this.updateSelectedCount(); // Update caret + + // Add animation class after a short delay to trigger transition + setTimeout(() => strip.classList.add('visible'), 10); + } + + hideThumbnailStrip() { + const strip = document.querySelector('.selected-thumbnails-strip'); + if (strip) { + strip.classList.remove('visible'); + + // Update strip visibility state and caret direction + this.isStripVisible = false; + this.updateSelectedCount(); // Update caret + + // Wait for animation to complete before removing + setTimeout(() => { + if (strip.parentNode) { + strip.parentNode.removeChild(strip); + } + }, 300); + } + } + + updateThumbnailStrip() { + const container = document.querySelector('.thumbnails-container'); + if (!container) return; + + // Clear existing thumbnails + container.innerHTML = ''; + + // Add a thumbnail for each selected LoRA + for (const filepath of state.selectedLoras) { + const metadata = state.loraMetadataCache.get(filepath); + if (!metadata) continue; + + const thumbnail = document.createElement('div'); + thumbnail.className = 'selected-thumbnail'; + thumbnail.dataset.filepath = filepath; + + // Create the visual element (image or video) + if (metadata.isVideo) { + thumbnail.innerHTML = ` + + ${metadata.modelName} + + `; + } else { + thumbnail.innerHTML = ` + ${metadata.modelName} + ${metadata.modelName} + + `; + } + + // Add click handler for deselection + thumbnail.addEventListener('click', (e) => { + if (!e.target.closest('.thumbnail-remove')) { + this.deselectItem(filepath); + } + }); + + // Add click handler for the remove button + thumbnail.querySelector('.thumbnail-remove').addEventListener('click', (e) => { + e.stopPropagation(); + this.deselectItem(filepath); + }); + + container.appendChild(thumbnail); + } + } + + deselectItem(filepath) { + // Find and deselect the corresponding card if it's in the DOM + const card = document.querySelector(`.lora-card[data-filepath="${filepath}"]`); + if (card) { + card.classList.remove('selected'); + } + + // Remove from the selection set + state.selectedLoras.delete(filepath); + + // Update UI + this.updateSelectedCount(); + this.updateThumbnailStrip(); + + // Hide the strip if no more selections + if (state.selectedLoras.size === 0) { + this.hideThumbnailStrip(); + } + } } // Create a singleton instance diff --git a/templates/components/controls.html b/templates/components/controls.html index 350b5343..30394be3 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -72,7 +72,9 @@