diff --git a/static/css/components/modal/download-modal.css b/static/css/components/modal/download-modal.css index d146b87b..ed85491d 100644 --- a/static/css/components/modal/download-modal.css +++ b/static/css/components/modal/download-modal.css @@ -502,4 +502,143 @@ opacity: 0.5; pointer-events: none; user-select: none; +} + +/* Textarea for multi-URL input */ +#modelUrl { + width: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + background: var(--bg-color); + color: var(--text-color); + font-family: monospace; + font-size: 0.9em; + resize: vertical; + line-height: 1.5; +} + +/* Batch Preview List */ +.batch-preview-list { + max-height: 400px; + overflow-y: auto; + margin: var(--space-2) 0; + display: flex; + flex-direction: column; + gap: 1px; + background: var(--border-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); +} + +.batch-preview-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: var(--bg-color); +} + +.batch-preview-item:first-child { + border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0; +} + +.batch-preview-item:last-child { + border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm); +} + +.batch-preview-item:only-child { + border-radius: var(--border-radius-sm); +} + +.batch-preview-thumbnail { + width: 48px; + height: 48px; + flex-shrink: 0; + border-radius: var(--border-radius-xs); + overflow: hidden; + background: var(--lora-surface); +} + +.batch-preview-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.batch-preview-icon { + width: 48px; + height: 48px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + color: #e74c3c; + font-size: 1.2em; +} + +.batch-preview-info { + flex: 1; + min-width: 0; +} + +.batch-preview-name { + font-weight: 500; + color: var(--text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.batch-preview-meta { + display: flex; + gap: 8px; + font-size: 0.85em; + color: var(--text-color); + opacity: 0.7; + margin-top: 2px; +} + +.batch-preview-error-text { + color: #e74c3c; + opacity: 1; +} + +.batch-preview-local-badge { + color: var(--lora-accent); + opacity: 1; +} + +.batch-preview-local { + opacity: 0.6; +} + +.batch-preview-change-version { + flex-shrink: 0; + font-size: 0.85em; + padding: 4px 10px; +} + +.batch-preview-remove { + flex-shrink: 0; + background: none; + border: none; + color: var(--text-color); + opacity: 0.5; + cursor: pointer; + padding: 4px 8px; + font-size: 1em; +} + +.batch-preview-remove:hover { + opacity: 1; + color: #e74c3c; +} + +.batch-preview-error { + background: oklch(0.5 0.15 25 / 0.05); +} + +[data-theme="dark"] .batch-preview-item { + background: var(--lora-surface); } \ No newline at end of file diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index 52ed7085..b6992fca 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -22,6 +22,11 @@ export class DownloadManager { this.apiClient = null; this.useDefaultPath = false; + // Batch mode state + this.batchModels = []; + this.isBatchMode = false; + this.editingBatchIndex = -1; + this.loadingManager = new LoadingManager(); this.folderTreeManager = new FolderTreeManager(); this.folderClickHandler = null; @@ -35,6 +40,8 @@ export class DownloadManager { this.handleBackToVersions = this.backToVersions.bind(this); this.handleCloseModal = this.closeModal.bind(this); this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this); + this.handleBackToUrlFromBatch = this.backToUrlFromBatch.bind(this); + this.handleNextFromBatch = this.nextFromBatch.bind(this); } showDownloadModal() { @@ -80,6 +87,10 @@ export class DownloadManager { document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions); document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal); + // Batch preview buttons + document.getElementById('backToUrlFromBatchBtn').addEventListener('click', this.handleBackToUrlFromBatch); + document.getElementById('nextFromBatchBtn').addEventListener('click', this.handleNextFromBatch); + // Default path toggle handler document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath); } @@ -131,6 +142,9 @@ export class DownloadManager { this.source = null; this.selectedFolder = ''; + this.batchModels = []; + this.isBatchMode = false; + this.editingBatchIndex = -1; // Clear folder tree selection if (this.folderTreeManager) { @@ -150,30 +164,99 @@ export class DownloadManager { } async validateAndFetchVersions() { - const url = document.getElementById('modelUrl').value.trim(); + const rawText = document.getElementById('modelUrl').value.trim(); const errorElement = document.getElementById('urlError'); + const urls = rawText.split('\n').map(l => l.trim()).filter(Boolean); - try { - this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions')); - - this.modelId = this.extractModelId(url); - if (!this.modelId) { - throw new Error(translate('modals.download.errors.invalidUrl')); - } - - await this.retrieveVersionsForModel(this.modelId, this.source); - - // If we have a version ID from URL, pre-select it - if (this.modelVersionId) { - this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId); - } - - this.showVersionStep(); - } catch (error) { - errorElement.textContent = error.message; - } finally { - this.loadingManager.hide(); + if (urls.length === 0) { + errorElement.textContent = translate('modals.download.errors.invalidUrl'); + return; } + + if (urls.length === 1) { + this.isBatchMode = false; + try { + this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions')); + + this.modelId = this.extractModelId(urls[0]); + if (!this.modelId) { + throw new Error(translate('modals.download.errors.invalidUrl')); + } + + await this.retrieveVersionsForModel(this.modelId, this.source); + + if (this.modelVersionId) { + this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId); + } + + this.showVersionStep(); + } catch (error) { + errorElement.textContent = error.message; + } finally { + this.loadingManager.hide(); + } + return; + } + + // Multi-URL batch mode + this.isBatchMode = true; + this.batchModels = []; + errorElement.textContent = ''; + + const seen = new Set(); + const parsed = []; + for (const url of urls) { + const result = DownloadManager.parseModelUrl(url); + if (!result.modelId) { + parsed.push({ url, error: translate('modals.download.errors.invalidUrl') }); + continue; + } + if (seen.has(result.modelId)) continue; + seen.add(result.modelId); + parsed.push({ url, ...result, error: null }); + } + + if (parsed.length === 0) { + errorElement.textContent = translate('modals.download.errors.invalidUrl'); + return; + } + + this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions')); + + let fetched = 0; + const total = parsed.filter(p => !p.error).length; + + this.batchModels = new Array(parsed.length); + + const fetchPromises = parsed.map(async (item, index) => { + if (item.error) { + this.batchModels[index] = { ...item, versions: [], selectedVersion: null }; + return; + } + try { + const versions = await this.apiClient.fetchCivitaiVersions(item.modelId, item.source); + fetched++; + this.loadingManager.setStatus(`${fetched}/${total}`); + + let selectedVersion = null; + if (versions && versions.length > 0) { + if (item.modelVersionId) { + selectedVersion = versions.find(v => v.id.toString() === item.modelVersionId) || versions[0]; + } else { + selectedVersion = versions[0]; + } + } + + this.batchModels[index] = { ...item, versions: versions || [], selectedVersion }; + } catch (err) { + this.batchModels[index] = { ...item, versions: [], selectedVersion: null, error: err.message }; + } + }); + + await Promise.all(fetchPromises); + this.loadingManager.hide(); + + this.showBatchPreviewStep(); } async fetchVersionsForCurrentModel() { @@ -197,25 +280,30 @@ export class DownloadManager { } } - extractModelId(url) { + static parseModelUrl(url) { const civarchiveMatch = url.match(/https?:\/\/(?:www\.)?(?:civitaiarchive|civarchive)\.com\/models\/(\d+)/i); if (civarchiveMatch) { const versionMatch = url.match(/modelVersionId=(\d+)/i); - this.modelVersionId = versionMatch ? versionMatch[1] : null; - this.source = 'civarchive'; - return civarchiveMatch[1]; + return { + modelId: civarchiveMatch[1], + modelVersionId: versionMatch ? versionMatch[1] : null, + source: 'civarchive', + }; } const { modelId, modelVersionId } = extractCivitaiModelUrlParts(url); if (modelId) { - this.modelVersionId = modelVersionId; - this.source = null; - return modelId; + return { modelId, modelVersionId, source: null }; } - this.modelVersionId = null; - this.source = null; - return null; + return { modelId: null, modelVersionId: null, source: null }; + } + + extractModelId(url) { + const result = DownloadManager.parseModelUrl(url); + this.modelVersionId = result.modelVersionId; + this.source = result.source; + return result.modelId; } async openForModelVersion(modelType, modelId, versionId = null) { @@ -243,7 +331,10 @@ export class DownloadManager { document.getElementById('versionStep').style.display = 'block'; const versionList = document.getElementById('versionList'); - versionList.innerHTML = this.versions.map(version => { + const newList = versionList.cloneNode(false); + versionList.parentNode.replaceChild(newList, versionList); + + newList.innerHTML = this.versions.map(version => { const firstImage = version.images?.find(img => !img.url.endsWith('.mp4')); const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png'; @@ -309,7 +400,7 @@ export class DownloadManager { }).join(''); // Add click handlers for version selection - versionList.addEventListener('click', (event) => { + newList.addEventListener('click', (event) => { const versionItem = event.target.closest('.version-item'); if (versionItem) { this.selectVersion(versionItem.dataset.versionId); @@ -353,18 +444,30 @@ export class DownloadManager { } async proceedToLocation() { - if (!this.currentVersion) { - showToast('toast.loras.pleaseSelectVersion', {}, 'error'); + // If editing a batch item's version, save and return to batch preview + if (this.isBatchMode && this.editingBatchIndex >= 0) { + if (this.currentVersion) { + this.batchModels[this.editingBatchIndex].selectedVersion = this.currentVersion; + } + this.editingBatchIndex = -1; + document.getElementById('versionStep').style.display = 'none'; + this.showBatchPreviewStep(); return; } - const existsLocally = this.currentVersion.existsLocally; - if (existsLocally) { - showToast('toast.loras.versionExists', {}, 'info'); - return; + // In single-URL mode, validate version selection + if (!this.isBatchMode) { + if (!this.currentVersion) { + showToast('toast.loras.pleaseSelectVersion', {}, 'error'); + return; + } + if (this.currentVersion.existsLocally) { + showToast('toast.loras.versionExists', {}, 'info'); + return; + } } - document.getElementById('versionStep').style.display = 'none'; + document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none'); document.getElementById('locationStep').style.display = 'block'; try { @@ -595,14 +698,123 @@ export class DownloadManager { this.updateTargetPath(); } + showBatchPreviewStep() { + document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none'); + document.getElementById('batchPreviewStep').style.display = 'block'; + + const validCount = this.batchModels.filter(m => !m.error && m.selectedVersion).length; + document.getElementById('downloadModalTitle').textContent = + translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) + + ` (${validCount})`; + + const list = document.getElementById('batchPreviewList'); + list.innerHTML = this.batchModels.map((item, index) => { + if (item.error) { + return ` +