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