diff --git a/static/css/components/modal/download-modal.css b/static/css/components/modal/download-modal.css index f0975456..93a8ad14 100644 --- a/static/css/components/modal/download-modal.css +++ b/static/css/components/modal/download-modal.css @@ -577,13 +577,14 @@ border: 1px solid var(--border-color); border-radius: var(--border-radius-sm); cursor: pointer; - transition: var(--transition-base); + transition: var(--transition-base), box-shadow var(--transition-fast), transform var(--transition-fast); background: var(--bg-color); } .file-option:hover { border-color: var(--lora-accent); - box-shadow: var(--shadow-sm); + box-shadow: var(--shadow-md); + transform: translateY(-1px); } .file-option.selected { @@ -698,10 +699,25 @@ color: var(--lora-accent); } -/* Batch Preview List */ +/* BUG 1 FIX: Single scrollbar — modal-content becomes a flex column so the + batch preview step can flex; the list scrolls instead of the modal-content. */ +#downloadModal .modal-content { + display: flex; + flex-direction: column; +} + +#batchPreviewStep { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; +} + +/* Batch Preview List — no max-height; flexes inside #batchPreviewStep */ .batch-preview-list { - max-height: 400px; + flex: 1; overflow-y: auto; + min-height: 0; margin: var(--space-2) 0; display: flex; flex-direction: column; @@ -859,6 +875,8 @@ position: sticky; top: 0; z-index: 1; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); } .batch-preview-select-all input[type="checkbox"] { @@ -884,3 +902,100 @@ [data-theme="dark"] .batch-preview-select-all { background: var(--lora-surface); } + +/* FEATURE 2: HF repo grouping — collapsible groups by repo */ +.batch-preview-group { + display: flex; + flex-direction: column; + background: var(--surface-base); +} + +.batch-preview-group-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: var(--color-accent-subtle); + border-bottom: 1px solid var(--color-accent-border); + cursor: pointer; + user-select: none; + transition: background var(--transition-fast); +} + +.batch-preview-group-header:hover { + background: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.18); +} + +.batch-preview-group-toggle { + width: 14px; + font-size: 0.75em; + color: var(--text-color); + opacity: 0.7; + transition: transform var(--transition-fast); + flex-shrink: 0; +} + +.batch-preview-group-toggle.expanded { + transform: rotate(90deg); +} + +.batch-preview-group-name { + flex: 1; + min-width: 0; + font-weight: 600; + color: var(--text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.95em; +} + +.batch-preview-group-count { + font-size: 0.8em; + color: var(--text-color); + opacity: 0.7; + flex-shrink: 0; +} + +.batch-preview-group-select-all { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: var(--lora-accent); + flex-shrink: 0; + padding: 0; + margin: 0; +} + +.batch-preview-group-body { + display: flex; + flex-direction: column; + gap: 1px; + background: var(--border-color); + overflow: hidden; + max-height: 0; + opacity: 0; + transition: max-height 0.35s ease, opacity 0.2s ease; +} + +.batch-preview-group-body.expanded { + opacity: 1; + max-height: 9999px; /* rest state: content visible; JS inline style overrides during transitions */ +} + +/* Dark theme overrides for group styles */ +[data-theme="dark"] .batch-preview-group { + background: var(--surface-base); +} + +[data-theme="dark"] .batch-preview-group-header { + background: var(--color-accent-subtle); +} + +[data-theme="dark"] .batch-preview-group-header:hover { + background: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.22); +} + +[data-theme="dark"] .batch-preview-group-body { + background: var(--border-color); +} diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index 225defa1..674f4a43 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -31,6 +31,7 @@ export class DownloadManager { // HF download state this.hfRepoId = null; this.hfSelectedFiles = []; + this.hfRepoCollapsed = {}; this.loadingManager = new LoadingManager(); this.folderTreeManager = new FolderTreeManager(); @@ -174,6 +175,7 @@ export class DownloadManager { // Reset HF state this.hfRepoId = null; this.hfSelectedFiles = []; + this.hfRepoCollapsed = {}; } async retrieveVersionsForModel(modelId, source = null) { @@ -1078,7 +1080,7 @@ export class DownloadManager { showBatchPreviewStep() { document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none'); - document.getElementById('batchPreviewStep').style.display = 'block'; + document.getElementById('batchPreviewStep').style.display = 'flex'; const validCount = this.batchModels.filter(m => { if (m.error) return false; @@ -1092,56 +1094,36 @@ export class DownloadManager { const list = document.getElementById('batchPreviewList'); const hasHfItems = this.batchModels.some(m => m.source === 'huggingface' && !m.error); - let itemsHtml = this.batchModels.map((item, index) => { - if (item.error) { - return ` -
-
- -
-
-
${item.url}
-
${item.error}
-
- + // Error items render flat, outside any group + const errorItemsHtml = this.batchModels.map((item, index) => { + if (!item.error) return null; + return ` +
+
+
- `; - } +
+
${item.url}
+
${item.error}
+
+ +
+ `; + }).filter(Boolean).join(''); + // CivitAI items render flat, outside any group (unchanged) + const civitaiItemsHtml = this.batchModels.map((item, index) => { + if (item.error) return null; + if (item.source === 'huggingface') return null; const ver = item.selectedVersion; - - // HF batch item rendering with checkbox - if (item.source === 'huggingface') { - const hfSize = item.fileSizeBytes - ? formatFileSize(item.fileSizeBytes) - : '?'; - return ` -
- -
-
${item.displayName || item.filename || `HF #${index}`} HF
-
- ${hfSize} - ${item.repo || ''} -
-
- -
- `; - } - const firstImage = ver?.images?.find(img => !img.url.endsWith('.mp4')); const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png'; const fileSize = ver?.modelSizeKB ? (ver.modelSizeKB / 1024).toFixed(1) : (ver?.files?.[0]?.sizeKB ? (ver.files[0].sizeKB / 1024).toFixed(1) : '?'); const existsLocally = ver?.existsLocally; - return `
@@ -1162,8 +1144,59 @@ export class DownloadManager { ` : ''}
`; + }).filter(Boolean).join(''); + + // Group HF items by repo (data model stays flat — only rendering groups) + const hfGroups = {}; + this.batchModels.forEach((item, index) => { + if (item.error || item.source !== 'huggingface') return; + const repo = item.repo || 'unknown'; + if (!hfGroups[repo]) hfGroups[repo] = []; + hfGroups[repo].push({ item, index }); + }); + + const renderHfItem = ({ item, index }) => { + const hfSize = item.fileSizeBytes ? formatFileSize(item.fileSizeBytes) : '?'; + return ` +
+ +
+
${item.displayName || item.filename || `HF #${index}`} HF
+
+ ${hfSize} + ${item.repo || ''} +
+
+ +
+ `; + }; + + const hfGroupsHtml = Object.keys(hfGroups).map(repo => { + const items = hfGroups[repo]; + const isCollapsed = this.hfRepoCollapsed[repo] === true; + const allChecked = items.every(({ item }) => item.checked !== false); + const fileCount = items.length; + return ` +
+
+ + ${repo} + ${fileCount} ${translate('modals.download.fileSelection.files', {}, 'files')} + +
+
+ ${items.map(renderHfItem).join('')} +
+
+ `; }).join(''); + let itemsHtml = errorItemsHtml + civitaiItemsHtml + hfGroupsHtml; + // Prepend select-all toolbar if there are HF items with checkboxes if (hasHfItems) { const allChecked = this.batchModels @@ -1179,7 +1212,90 @@ export class DownloadManager { list.innerHTML = itemsHtml; + const updateCountAndSelectAll = () => { + const checkedCount = this.batchModels.filter( + m => !m.error && m.checked !== false + ).length; + document.getElementById('downloadModalTitle').textContent = + translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) + + ` (${checkedCount})`; + const nextBtn = document.getElementById('nextFromBatchBtn'); + nextBtn.disabled = checkedCount === 0; + nextBtn.classList.toggle('disabled', checkedCount === 0); + // Global select-all + const selectAll = document.getElementById('batchSelectAll'); + if (selectAll) { + const hfItems = this.batchModels.filter(m => m.source === 'huggingface' && !m.error); + selectAll.checked = hfItems.length > 0 && hfItems.every(m => m.checked !== false); + } + // Per-group select-all + list.querySelectorAll('.batch-preview-group-select-all').forEach(gsa => { + const repo = gsa.dataset.repo; + const repoItems = this.batchModels.filter(m => m.source === 'huggingface' && !m.error && m.repo === repo); + gsa.checked = repoItems.length > 0 && repoItems.every(m => m.checked !== false); + }); + }; + list.onclick = (e) => { + // Per-group select-all checkbox + const groupSelectAll = e.target.closest('.batch-preview-group-select-all'); + if (groupSelectAll) { + const repo = groupSelectAll.dataset.repo; + const checked = groupSelectAll.checked; + this.batchModels.forEach((m, idx) => { + if (m.source === 'huggingface' && !m.error && m.repo === repo) { + m.checked = checked; + const cb = list.querySelector(`.batch-preview-checkbox[data-index="${idx}"]`); + if (cb) cb.checked = checked; + } + }); + updateCountAndSelectAll(); + return; + } + + const header = e.target.closest('.batch-preview-group-header'); + if (header) { + const group = header.closest('.batch-preview-group'); + const repo = group.dataset.repo; + const body = group.querySelector('.batch-preview-group-body'); + const toggle = group.querySelector('.batch-preview-group-toggle'); + const isCollapsed = this.hfRepoCollapsed[repo]; + if (isCollapsed) { + this.hfRepoCollapsed[repo] = false; + body.style.transition = ''; // restore in case collapse was interrupted + body.classList.add('expanded'); + toggle.classList.add('expanded'); + // force reflow so expanded class is registered before setting height + void body.offsetHeight; + body.style.maxHeight = body.scrollHeight + 'px'; + const onEnd = (e) => { + if (e.propertyName !== 'max-height') return; + if (this.hfRepoCollapsed[repo] !== false) return; + body.style.maxHeight = ''; // fall back to .expanded's 9999px + body.removeEventListener('transitionend', onEnd); + }; + body.addEventListener('transitionend', onEnd); + } else { + this.hfRepoCollapsed[repo] = true; + body.style.maxHeight = body.scrollHeight + 'px'; + requestAnimationFrame(() => { + // animate only max-height; keep expanded so opacity stays 1 + body.style.transition = 'max-height 0.35s ease'; + body.style.maxHeight = '0'; + toggle.classList.remove('expanded'); + const onEnd = (e) => { + if (e.propertyName !== 'max-height') return; + if (this.hfRepoCollapsed[repo] !== true) return; // state changed since + body.classList.remove('expanded'); + body.style.transition = ''; + body.removeEventListener('transitionend', onEnd); + }; + body.addEventListener('transitionend', onEnd); + }); + } + return; + } + const removeBtn = e.target.closest('.batch-preview-remove'); if (removeBtn) { const idx = parseInt(removeBtn.dataset.index); @@ -1194,7 +1310,7 @@ export class DownloadManager { } }; - // Checkbox handler for HF batch items + // Individual HF checkbox handler const checkboxes = list.querySelectorAll('.batch-preview-checkbox'); checkboxes.forEach(cb => { cb.addEventListener('change', (e) => { @@ -1202,26 +1318,11 @@ export class DownloadManager { if (this.batchModels[idx]) { this.batchModels[idx].checked = e.target.checked; } - // Update valid count in title and Next button - const checkedCount = this.batchModels.filter( - m => !m.error && m.checked !== false - ).length; - document.getElementById('downloadModalTitle').textContent = - translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) + - ` (${checkedCount})`; - const nextBtn = document.getElementById('nextFromBatchBtn'); - nextBtn.disabled = checkedCount === 0; - nextBtn.classList.toggle('disabled', checkedCount === 0); - // Update select-all checkbox state - const selectAll = document.getElementById('batchSelectAll'); - if (selectAll) { - const hfItems = this.batchModels.filter(m => m.source === 'huggingface' && !m.error); - selectAll.checked = hfItems.length > 0 && hfItems.every(m => m.checked !== false); - } + updateCountAndSelectAll(); }); }); - // Select-all handler + // Global select-all handler const selectAll = document.getElementById('batchSelectAll'); if (selectAll) { selectAll.addEventListener('change', (e) => { @@ -1234,16 +1335,7 @@ export class DownloadManager { this.batchModels[idx].checked = checked; } }); - // Update valid count in title and Next button - const checkedCount = this.batchModels.filter( - m => !m.error && m.checked !== false - ).length; - document.getElementById('downloadModalTitle').textContent = - translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) + - ` (${checkedCount})`; - const nextBtn = document.getElementById('nextFromBatchBtn'); - nextBtn.disabled = checkedCount === 0; - nextBtn.classList.toggle('disabled', checkedCount === 0); + updateCountAndSelectAll(); }); }