diff --git a/static/js/managers/ImportManager.js b/static/js/managers/ImportManager.js index ae39a141..bec00d73 100644 --- a/static/js/managers/ImportManager.js +++ b/static/js/managers/ImportManager.js @@ -2,34 +2,41 @@ import { modalManager } from './ModalManager.js'; import { showToast } from '../utils/uiHelpers.js'; import { LoadingManager } from './LoadingManager.js'; import { getStorageItem } from '../utils/storageHelpers.js'; +import { ImportStepManager } from './import/ImportStepManager.js'; +import { ImageProcessor } from './import/ImageProcessor.js'; +import { RecipeDataManager } from './import/RecipeDataManager.js'; +import { DownloadManager } from './import/DownloadManager.js'; +import { FolderBrowser } from './import/FolderBrowser.js'; +import { formatFileSize } from '../utils/formatters.js'; export class ImportManager { constructor() { + // Core state properties this.recipeImage = null; this.recipeData = null; this.recipeName = ''; this.recipeTags = []; this.missingLoras = []; - - // Add initialization check this.initialized = false; this.selectedFolder = ''; - - // Add LoadingManager instance + this.downloadableLoRAs = []; + this.recipeId = null; + this.importMode = 'url'; // Default mode: 'url' or 'upload' + + // Initialize sub-managers this.loadingManager = new LoadingManager(); - this.folderClickHandler = null; - this.updateTargetPath = this.updateTargetPath.bind(this); + this.stepManager = new ImportStepManager(); + this.imageProcessor = new ImageProcessor(this); + this.recipeDataManager = new RecipeDataManager(this); + this.downloadManager = new DownloadManager(this); + this.folderBrowser = new FolderBrowser(this); - // 添加对注入样式的引用 - this.injectedStyles = null; - - // Change default mode to url/path - this.importMode = 'url'; // Default mode changed to: 'url' or 'upload' + // Bind methods + this.formatFileSize = formatFileSize; } showImportModal(recipeData = null, recipeId = null) { if (!this.initialized) { - // Check if modal exists const modal = document.getElementById('importModal'); if (!modal) { console.error('Import modal element not found'); @@ -38,82 +45,46 @@ export class ImportManager { this.initialized = true; } - // Always reset the state when opening the modal + // Reset state this.resetSteps(); if (recipeData) { this.downloadableLoRAs = recipeData.loras; this.recipeId = recipeId; } - // Show the modal + // Show modal modalManager.showModal('importModal', null, () => { - // Cleanup handler when modal closes - this.cleanupFolderBrowser(); - - // Remove any injected styles - this.removeInjectedStyles(); + this.folderBrowser.cleanup(); + this.stepManager.removeInjectedStyles(); }); - // Verify the modal is properly shown - setTimeout(() => { - this.ensureModalVisible(); - }, 50); - } - - // 添加移除注入样式的方法 - removeInjectedStyles() { - if (this.injectedStyles && this.injectedStyles.parentNode) { - this.injectedStyles.parentNode.removeChild(this.injectedStyles); - this.injectedStyles = null; - } - - // Also reset any inline styles that might have been set with !important - document.querySelectorAll('.import-step').forEach(step => { - step.style.cssText = ''; - }); + // Verify visibility + setTimeout(() => this.ensureModalVisible(), 50); } resetSteps() { - // Remove any existing injected styles - this.removeInjectedStyles(); + // Clear UI state + this.stepManager.removeInjectedStyles(); + this.stepManager.showStep('uploadStep'); - // Show the first step - this.showStep('uploadStep'); - - // Reset file input + // Reset form inputs const fileInput = document.getElementById('recipeImageUpload'); - if (fileInput) { - fileInput.value = ''; - } + if (fileInput) fileInput.value = ''; - // Reset URL input const urlInput = document.getElementById('imageUrlInput'); - if (urlInput) { - urlInput.value = ''; - } + if (urlInput) urlInput.value = ''; - // Reset error messages const uploadError = document.getElementById('uploadError'); - if (uploadError) { - uploadError.textContent = ''; - } + if (uploadError) uploadError.textContent = ''; const urlError = document.getElementById('urlError'); - if (urlError) { - urlError.textContent = ''; - } + if (urlError) urlError.textContent = ''; - // Reset recipe name input const recipeName = document.getElementById('recipeName'); - if (recipeName) { - recipeName.value = ''; - } + if (recipeName) recipeName.value = ''; - // Reset tags container const tagsContainer = document.getElementById('tagsContainer'); - if (tagsContainer) { - tagsContainer.innerHTML = '
No tags added
'; - } + if (tagsContainer) tagsContainer.innerHTML = '
No tags added
'; // Reset state variables this.recipeImage = null; @@ -123,11 +94,11 @@ export class ImportManager { this.missingLoras = []; this.downloadableLoRAs = []; - // Reset import mode to url/path instead of upload + // Reset import mode this.importMode = 'url'; this.toggleImportMode('url'); - // Clear selected folder and remove selection from UI + // Reset folder browser this.selectedFolder = ''; const folderBrowser = document.getElementById('importFolderBrowser'); if (folderBrowser) { @@ -135,29 +106,20 @@ export class ImportManager { f.classList.remove('selected')); } - // Clear missing LoRAs list if it exists + // Clear missing LoRAs list const missingLorasList = document.getElementById('missingLorasList'); - if (missingLorasList) { - missingLorasList.innerHTML = ''; - } + if (missingLorasList) missingLorasList.innerHTML = ''; // Reset total download size const totalSizeDisplay = document.getElementById('totalDownloadSize'); - if (totalSizeDisplay) { - totalSizeDisplay.textContent = 'Calculating...'; - } + if (totalSizeDisplay) totalSizeDisplay.textContent = 'Calculating...'; - // Remove any existing deleted LoRAs warning + // Remove warnings const deletedLorasWarning = document.getElementById('deletedLorasWarning'); - if (deletedLorasWarning) { - deletedLorasWarning.remove(); - } + if (deletedLorasWarning) deletedLorasWarning.remove(); - // Remove any existing early access warning const earlyAccessWarning = document.getElementById('earlyAccessWarning'); - if (earlyAccessWarning) { - earlyAccessWarning.remove(); - } + if (earlyAccessWarning) earlyAccessWarning.remove(); } toggleImportMode(mode) { @@ -200,433 +162,19 @@ export class ImportManager { } handleImageUpload(event) { - const file = event.target.files[0]; - const errorElement = document.getElementById('uploadError'); - - if (!file) { - return; - } - - // Validate file type - if (!file.type.match('image.*')) { - errorElement.textContent = 'Please select an image file'; - return; - } - - // Reset error - errorElement.textContent = ''; - this.recipeImage = file; - - // Auto-proceed to next step if file is selected - this.uploadAndAnalyzeImage(); + this.imageProcessor.handleFileUpload(event); } async handleUrlInput() { - const urlInput = document.getElementById('imageUrlInput'); - const errorElement = document.getElementById('urlError'); - const input = urlInput.value.trim(); - - // Validate input - if (!input) { - errorElement.textContent = 'Please enter a URL or file path'; - return; - } - - // Reset error - errorElement.textContent = ''; - - // Show loading indicator - this.loadingManager.showSimpleLoading('Processing input...'); - - try { - // Check if it's a URL or a local file path - if (input.startsWith('http://') || input.startsWith('https://')) { - // Handle as URL - await this.analyzeImageFromUrl(input); - } else { - // Handle as local file path - await this.analyzeImageFromLocalPath(input); - } - } catch (error) { - errorElement.textContent = error.message || 'Failed to process input'; - } finally { - this.loadingManager.hide(); - } - } - - async analyzeImageFromUrl(url) { - try { - // Call the API with URL data - const response = await fetch('/api/recipes/analyze-image', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ url: url }) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to analyze image from URL'); - } - - // Get recipe data from response - this.recipeData = await response.json(); - - // Check if we have an error message - if (this.recipeData.error) { - throw new Error(this.recipeData.error); - } - - // Check if we have valid recipe data - if (!this.recipeData || !this.recipeData.loras || this.recipeData.loras.length === 0) { - throw new Error('No LoRA information found in this image'); - } - - // Find missing LoRAs - this.missingLoras = this.recipeData.loras.filter(lora => !lora.existsLocally); - - // Proceed to recipe details step - this.showRecipeDetailsStep(); - - } catch (error) { - console.error('Error analyzing URL:', error); - throw error; - } - } - - // Add new method to handle local file paths - async analyzeImageFromLocalPath(path) { - try { - // Call the API with local path data - const response = await fetch('/api/recipes/analyze-local-image', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ path: path }) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to load image from local path'); - } - - // Get recipe data from response - this.recipeData = await response.json(); - - // Check if we have an error message - if (this.recipeData.error) { - throw new Error(this.recipeData.error); - } - - // Check if we have valid recipe data - if (!this.recipeData || !this.recipeData.loras || this.recipeData.loras.length === 0) { - throw new Error('No LoRA information found in this image'); - } - - // Find missing LoRAs - this.missingLoras = this.recipeData.loras.filter(lora => !lora.existsLocally); - - // Proceed to recipe details step - this.showRecipeDetailsStep(); - - } catch (error) { - console.error('Error analyzing local path:', error); - throw error; - } + await this.imageProcessor.handleUrlInput(); } async uploadAndAnalyzeImage() { - if (!this.recipeImage) { - showToast('Please select an image first', 'error'); - return; - } - - try { - this.loadingManager.showSimpleLoading('Analyzing image metadata...'); - - // Create form data for upload - const formData = new FormData(); - formData.append('image', this.recipeImage); - - // Upload image for analysis - const response = await fetch('/api/recipes/analyze-image', { - method: 'POST', - body: formData - }); - - // Get recipe data from response - this.recipeData = await response.json(); - - console.log('Recipe data:', this.recipeData); - - // Check if we have an error message - if (this.recipeData.error) { - throw new Error(this.recipeData.error); - } - - // Check if we have valid recipe data - if (!this.recipeData || !this.recipeData.loras || this.recipeData.loras.length === 0) { - throw new Error('No LoRA information found in this image'); - } - - // Store generation parameters if available - if (this.recipeData.gen_params) { - console.log('Generation parameters found:', this.recipeData.gen_params); - } - - // Find missing LoRAs - this.missingLoras = this.recipeData.loras.filter(lora => !lora.existsLocally); - - // Proceed to recipe details step - this.showRecipeDetailsStep(); - - } catch (error) { - document.getElementById('uploadError').textContent = error.message; - } finally { - this.loadingManager.hide(); - } + await this.imageProcessor.uploadAndAnalyzeImage(); } showRecipeDetailsStep() { - this.showStep('detailsStep'); - - // Set default recipe name from prompt or image filename - const recipeName = document.getElementById('recipeName'); - - // Check if we have recipe metadata from a shared recipe - if (this.recipeData && this.recipeData.from_recipe_metadata) { - // Use title from recipe metadata - if (this.recipeData.title) { - recipeName.value = this.recipeData.title; - this.recipeName = this.recipeData.title; - } - - // Use tags from recipe metadata - if (this.recipeData.tags && Array.isArray(this.recipeData.tags)) { - this.recipeTags = [...this.recipeData.tags]; - this.updateTagsDisplay(); - } - } else if (this.recipeData && this.recipeData.gen_params && this.recipeData.gen_params.prompt) { - // Use the first 10 words from the prompt as the default recipe name - const promptWords = this.recipeData.gen_params.prompt.split(' '); - const truncatedPrompt = promptWords.slice(0, 10).join(' '); - recipeName.value = truncatedPrompt; - this.recipeName = truncatedPrompt; - - // Set up click handler to select all text for easy editing - if (!recipeName.hasSelectAllHandler) { - recipeName.addEventListener('click', function() { - this.select(); - }); - recipeName.hasSelectAllHandler = true; - } - } else if (this.recipeImage && !recipeName.value) { - // Fallback to image filename if no prompt is available - const fileName = this.recipeImage.name.split('.')[0]; - recipeName.value = fileName; - this.recipeName = fileName; - } - - // Always set up click handler for easy editing if not already set - if (!recipeName.hasSelectAllHandler) { - recipeName.addEventListener('click', function() { - this.select(); - }); - recipeName.hasSelectAllHandler = true; - } - - // Display the uploaded image in the preview - const imagePreview = document.getElementById('recipeImagePreview'); - if (imagePreview) { - if (this.recipeImage) { - // For file upload mode - const reader = new FileReader(); - reader.onload = (e) => { - imagePreview.innerHTML = `Recipe preview`; - }; - reader.readAsDataURL(this.recipeImage); - } else if (this.recipeData && this.recipeData.image_base64) { - // For URL mode - use the base64 image data returned from the backend - imagePreview.innerHTML = `Recipe preview`; - } else if (this.importMode === 'url') { - // Fallback for URL mode if no base64 data - const urlInput = document.getElementById('imageUrlInput'); - if (urlInput && urlInput.value) { - imagePreview.innerHTML = `Recipe preview`; - } - } - } - - // Update LoRA count information - const totalLoras = this.recipeData.loras.length; - const existingLoras = this.recipeData.loras.filter(lora => lora.existsLocally).length; - const loraCountInfo = document.getElementById('loraCountInfo'); - if (loraCountInfo) { - loraCountInfo.textContent = `(${existingLoras}/${totalLoras} in library)`; - } - - // Display LoRAs list - const lorasList = document.getElementById('lorasList'); - if (lorasList) { - lorasList.innerHTML = this.recipeData.loras.map(lora => { - const existsLocally = lora.existsLocally; - const isDeleted = lora.isDeleted; - const isEarlyAccess = lora.isEarlyAccess; - const localPath = lora.localPath || ''; - - // Create status badge based on LoRA status - let statusBadge; - if (isDeleted) { - statusBadge = `
- Deleted from Civitai -
`; - } else { - statusBadge = existsLocally ? - `
- In Library -
${localPath}
-
` : - `
- Not in Library -
`; - } - - // Early access badge (shown additionally with other badges) - let earlyAccessBadge = ''; - if (isEarlyAccess) { - // Format the early access end date if available - let earlyAccessInfo = 'This LoRA requires early access payment to download.'; - if (lora.earlyAccessEndsAt) { - try { - const endDate = new Date(lora.earlyAccessEndsAt); - const formattedDate = endDate.toLocaleDateString(); - earlyAccessInfo += ` Early access ends on ${formattedDate}.`; - } catch (e) { - console.warn('Failed to format early access date', e); - } - } - - earlyAccessBadge = `
- Early Access -
${earlyAccessInfo} Verify that you have purchased early access before downloading.
-
`; - } - - // Format size if available - const sizeDisplay = lora.size ? - `
${this.formatFileSize(lora.size)}
` : ''; - - return ` -
-
- LoRA preview -
-
-
-

${lora.name}

-
- ${statusBadge} - ${earlyAccessBadge} -
-
- ${lora.version ? `
${lora.version}
` : ''} -
- ${lora.baseModel ? `
${lora.baseModel}
` : ''} - ${sizeDisplay} -
Weight: ${lora.weight || 1.0}
-
-
-
- `; - }).join(''); - } - - // Check for early access loras and show warning if any exist - const earlyAccessLoras = this.recipeData.loras.filter(lora => - lora.isEarlyAccess && !lora.existsLocally && !lora.isDeleted); - if (earlyAccessLoras.length > 0) { - // Show a warning about early access loras - const warningMessage = ` -
-
-
-
${earlyAccessLoras.length} LoRA(s) require Early Access
-
- These LoRAs require a payment to access. Download will fail if you haven't purchased access. - You may need to log in to your Civitai account in browser settings. -
-
-
- `; - - // Show the warning message - const buttonsContainer = document.querySelector('#detailsStep .modal-actions'); - if (buttonsContainer) { - // Remove existing warning if any - const existingWarning = document.getElementById('earlyAccessWarning'); - if (existingWarning) { - existingWarning.remove(); - } - - // Add new warning - const warningContainer = document.createElement('div'); - warningContainer.id = 'earlyAccessWarning'; - warningContainer.innerHTML = warningMessage; - buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); - } - } - - // Update Next button state based on missing LoRAs - this.updateNextButtonState(); - } - - updateNextButtonState() { - const nextButton = document.querySelector('#detailsStep .primary-btn'); - if (!nextButton) return; - - // Always clean up previous warnings first - const existingWarning = document.getElementById('deletedLorasWarning'); - if (existingWarning) { - existingWarning.remove(); - } - - // Count deleted LoRAs - const deletedLoras = this.recipeData.loras.filter(lora => lora.isDeleted).length; - - // If we have deleted LoRAs, show a warning and update button text - if (deletedLoras > 0) { - // Create a new warning container above the buttons - const buttonsContainer = document.querySelector('#detailsStep .modal-actions') || nextButton.parentNode; - const warningContainer = document.createElement('div'); - warningContainer.id = 'deletedLorasWarning'; - warningContainer.className = 'deleted-loras-warning'; - - // Create warning message - warningContainer.innerHTML = ` -
-
-
${deletedLoras} LoRA(s) have been deleted from Civitai
-
These LoRAs cannot be downloaded. If you continue, they will remain in the recipe but won't be included when used.
-
- `; - - // Insert before the buttons container - buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); - } - - // If we have missing LoRAs (not deleted), show "Download Missing LoRAs" - // Otherwise show "Save Recipe" - const missingNotDeleted = this.recipeData.loras.filter( - lora => !lora.existsLocally && !lora.isDeleted - ).length; - - if (missingNotDeleted > 0) { - nextButton.textContent = 'Download Missing LoRAs'; - } else { - nextButton.textContent = 'Save Recipe'; - } + this.recipeDataManager.showRecipeDetailsStep(); } handleRecipeNameChange(event) { @@ -634,649 +182,52 @@ export class ImportManager { } addTag() { - const tagInput = document.getElementById('tagInput'); - const tag = tagInput.value.trim(); - - if (!tag) return; - - if (!this.recipeTags.includes(tag)) { - this.recipeTags.push(tag); - this.updateTagsDisplay(); - } - - tagInput.value = ''; + this.recipeDataManager.addTag(); } removeTag(tag) { - this.recipeTags = this.recipeTags.filter(t => t !== tag); - this.updateTagsDisplay(); - } - - updateTagsDisplay() { - const tagsContainer = document.getElementById('tagsContainer'); - - if (this.recipeTags.length === 0) { - tagsContainer.innerHTML = '
No tags added
'; - return; - } - - tagsContainer.innerHTML = this.recipeTags.map(tag => ` -
- ${tag} - -
- `).join(''); + this.recipeDataManager.removeTag(tag); } proceedFromDetails() { - // Validate recipe name - if (!this.recipeName) { - showToast('Please enter a recipe name', 'error'); - return; - } - - // Automatically mark all deleted LoRAs as excluded - if (this.recipeData && this.recipeData.loras) { - this.recipeData.loras.forEach(lora => { - if (lora.isDeleted) { - lora.exclude = true; - } - }); - } - - // Update missing LoRAs list to exclude deleted LoRAs - this.missingLoras = this.recipeData.loras.filter(lora => - !lora.existsLocally && !lora.isDeleted); - - // Check for early access loras and show warning if any exist - const earlyAccessLoras = this.missingLoras.filter(lora => lora.isEarlyAccess); - if (earlyAccessLoras.length > 0) { - // Show a warning about early access loras - const warningMessage = ` -
-
-
-
${earlyAccessLoras.length} LoRA(s) require Early Access
-
- These LoRAs require a payment to access. Download will fail if you haven't purchased access. - You may need to log in to your Civitai account in browser settings. -
-
-
- `; - - // Show the warning message - const buttonsContainer = document.querySelector('#detailsStep .modal-actions'); - if (buttonsContainer) { - // Remove existing warning if any - const existingWarning = document.getElementById('earlyAccessWarning'); - if (existingWarning) { - existingWarning.remove(); - } - - // Add new warning - const warningContainer = document.createElement('div'); - warningContainer.id = 'earlyAccessWarning'; - warningContainer.innerHTML = warningMessage; - buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); - } - } - - // If we have downloadable missing LoRAs, go to location step - if (this.missingLoras.length > 0) { - // Store only downloadable LoRAs for the download step - this.downloadableLoRAs = this.missingLoras; - this.proceedToLocation(); - } else { - // Otherwise, save the recipe directly - this.saveRecipe(); - } + this.recipeDataManager.proceedFromDetails(); } async proceedToLocation() { - - // Show the location step with special handling - this.showStep('locationStep'); - - // Double-check after a short delay to ensure the step is visible - setTimeout(() => { - const locationStep = document.getElementById('locationStep'); - if (locationStep.style.display !== 'block' || - window.getComputedStyle(locationStep).display !== 'block') { - // Force display again - locationStep.style.display = 'block'; - - // If still not visible, try with injected style - if (window.getComputedStyle(locationStep).display !== 'block') { - this.injectedStyles = document.createElement('style'); - this.injectedStyles.innerHTML = ` - #locationStep { - display: block !important; - opacity: 1 !important; - visibility: visible !important; - } - `; - document.head.appendChild(this.injectedStyles); - } - } - }, 100); - - try { - // Display missing LoRAs that will be downloaded - const missingLorasList = document.getElementById('missingLorasList'); - if (missingLorasList && this.downloadableLoRAs.length > 0) { - // Calculate total size - const totalSize = this.downloadableLoRAs.reduce((sum, lora) => { - return sum + (lora.size ? parseInt(lora.size) : 0); - }, 0); - - // Update total size display - const totalSizeDisplay = document.getElementById('totalDownloadSize'); - if (totalSizeDisplay) { - totalSizeDisplay.textContent = this.formatFileSize(totalSize); - } - - // Update header to include count of missing LoRAs - const missingLorasHeader = document.querySelector('.summary-header h3'); - if (missingLorasHeader) { - missingLorasHeader.innerHTML = `Missing LoRAs (${this.downloadableLoRAs.length}) ${this.formatFileSize(totalSize)}`; - } - - // Generate missing LoRAs list - missingLorasList.innerHTML = this.downloadableLoRAs.map(lora => { - const sizeDisplay = lora.size ? this.formatFileSize(lora.size) : 'Unknown size'; - const baseModel = lora.baseModel ? `${lora.baseModel}` : ''; - const isEarlyAccess = lora.isEarlyAccess; - - // Early access badge - let earlyAccessBadge = ''; - if (isEarlyAccess) { - earlyAccessBadge = ` - Early Access - `; - } - - return ` -
-
-
${lora.name}
- ${baseModel} - ${earlyAccessBadge} -
-
${sizeDisplay}
-
- `; - }).join(''); - - // Set up toggle for missing LoRAs list - const toggleBtn = document.getElementById('toggleMissingLorasList'); - if (toggleBtn) { - toggleBtn.addEventListener('click', () => { - missingLorasList.classList.toggle('collapsed'); - const icon = toggleBtn.querySelector('i'); - if (icon) { - icon.classList.toggle('fa-chevron-down'); - icon.classList.toggle('fa-chevron-up'); - } - }); - } - } - - // Fetch LoRA roots - const rootsResponse = await fetch('/api/lora-roots'); - if (!rootsResponse.ok) { - throw new Error(`Failed to fetch LoRA roots: ${rootsResponse.status}`); - } - - const rootsData = await rootsResponse.json(); - const loraRoot = document.getElementById('importLoraRoot'); - if (loraRoot) { - loraRoot.innerHTML = rootsData.roots.map(root => - `` - ).join(''); - - // Set default lora root if available - const defaultRoot = getStorageItem('settings', {}).default_loras_root; - if (defaultRoot && rootsData.roots.includes(defaultRoot)) { - loraRoot.value = defaultRoot; - } - } - - // Fetch folders - const foldersResponse = await fetch('/api/folders'); - if (!foldersResponse.ok) { - throw new Error(`Failed to fetch folders: ${foldersResponse.status}`); - } - - const foldersData = await foldersResponse.json(); - const folderBrowser = document.getElementById('importFolderBrowser'); - if (folderBrowser) { - folderBrowser.innerHTML = foldersData.folders.map(folder => - folder ? `
${folder}
` : '' - ).join(''); - } - - // Initialize folder browser after loading data - this.initializeFolderBrowser(); - } catch (error) { - console.error('Error in API calls:', error); - showToast(error.message, 'error'); - } + await this.folderBrowser.proceedToLocation(); } backToUpload() { - this.showStep('uploadStep'); + this.stepManager.showStep('uploadStep'); - // Reset file input to ensure it can trigger change events again + // Reset file input const fileInput = document.getElementById('recipeImageUpload'); - if (fileInput) { - fileInput.value = ''; - } + if (fileInput) fileInput.value = ''; // Reset URL input const urlInput = document.getElementById('imageUrlInput'); - if (urlInput) { - urlInput.value = ''; - } + if (urlInput) urlInput.value = ''; - // Clear any previous error messages + // Clear error messages const uploadError = document.getElementById('uploadError'); - if (uploadError) { - uploadError.textContent = ''; - } + if (uploadError) uploadError.textContent = ''; const urlError = document.getElementById('urlError'); - if (urlError) { - urlError.textContent = ''; - } + if (urlError) urlError.textContent = ''; } backToDetails() { - this.showStep('detailsStep'); + this.stepManager.showStep('detailsStep'); } async saveRecipe() { - // Check if we're in download-only mode (for existing recipe) - const isDownloadOnly = !!this.recipeId; - - console.log("isDownloadOnly", isDownloadOnly); - - if (!isDownloadOnly && !this.recipeName) { - showToast('Please enter a recipe name', 'error'); - return; - } - - try { - // Show progress indicator - this.loadingManager.showSimpleLoading(isDownloadOnly ? 'Downloading LoRAs...' : 'Saving recipe...'); - - // Only send the complete recipe to save if not in download-only mode - if (!isDownloadOnly) { - // Create FormData object for saving recipe - const formData = new FormData(); - - // Add image data - depends on import mode - if (this.recipeImage) { - // Direct upload - formData.append('image', this.recipeImage); - } else if (this.recipeData && this.recipeData.image_base64) { - // URL mode with base64 data - formData.append('image_base64', this.recipeData.image_base64); - } else if (this.importMode === 'url') { - // Fallback for URL mode - tell backend to fetch the image again - const urlInput = document.getElementById('imageUrlInput'); - if (urlInput && urlInput.value) { - formData.append('image_url', urlInput.value); - } else { - throw new Error('No image data available'); - } - } else { - throw new Error('No image data available'); - } - - formData.append('name', this.recipeName); - formData.append('tags', JSON.stringify(this.recipeTags)); - - // Prepare complete metadata including generation parameters - const completeMetadata = { - base_model: this.recipeData.base_model || "", - loras: this.recipeData.loras || [], - gen_params: this.recipeData.gen_params || {}, - raw_metadata: this.recipeData.raw_metadata || {} - }; - - // Add source_path to metadata to track where the recipe was imported from - if (this.importMode === 'url') { - const urlInput = document.getElementById('imageUrlInput'); - console.log("urlInput.value", urlInput.value); - if (urlInput && urlInput.value) { - completeMetadata.source_path = urlInput.value; - } - } - - formData.append('metadata', JSON.stringify(completeMetadata)); - - // Send save request - const response = await fetch('/api/recipes/save', { - method: 'POST', - body: formData - }); - - const result = await response.json(); - - if (!result.success) { - // Handle save error - console.error("Failed to save recipe:", result.error); - showToast(result.error, 'error'); - // Close modal - modalManager.closeModal('importModal'); - return; - } - } - - // Check if we need to download LoRAs - let failedDownloads = 0; - if (this.downloadableLoRAs && this.downloadableLoRAs.length > 0) { - // For download, we need to validate the target path - const loraRoot = document.getElementById('importLoraRoot')?.value; - if (!loraRoot) { - throw new Error('Please select a LoRA root directory'); - } - - // Build target path - let targetPath = loraRoot; - if (this.selectedFolder) { - targetPath += '/' + this.selectedFolder; - } - - const newFolder = document.getElementById('importNewFolder')?.value?.trim(); - if (newFolder) { - targetPath += '/' + newFolder; - } - - // Set up WebSocket for progress updates - const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; - const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`); - - // Show enhanced loading with progress details for multiple items - const updateProgress = this.loadingManager.showDownloadProgress(this.downloadableLoRAs.length); - - let completedDownloads = 0; - let accessFailures = 0; - let currentLoraProgress = 0; - - // Set up progress tracking for current download - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - if (data.status === 'progress') { - // Update current LoRA progress - currentLoraProgress = data.progress; - - // Get current LoRA name - const currentLora = this.downloadableLoRAs[completedDownloads + failedDownloads]; - const loraName = currentLora ? currentLora.name : ''; - - // Update progress display - updateProgress(currentLoraProgress, completedDownloads, loraName); - - // Add more detailed status messages based on progress - if (currentLoraProgress < 3) { - this.loadingManager.setStatus( - `Preparing download for LoRA ${completedDownloads + failedDownloads + 1}/${this.downloadableLoRAs.length}` - ); - } else if (currentLoraProgress === 3) { - this.loadingManager.setStatus( - `Downloaded preview for LoRA ${completedDownloads + failedDownloads + 1}/${this.downloadableLoRAs.length}` - ); - } else if (currentLoraProgress > 3 && currentLoraProgress < 100) { - this.loadingManager.setStatus( - `Downloading LoRA ${completedDownloads + failedDownloads + 1}/${this.downloadableLoRAs.length}` - ); - } else { - this.loadingManager.setStatus( - `Finalizing LoRA ${completedDownloads + failedDownloads + 1}/${this.downloadableLoRAs.length}` - ); - } - } - }; - - for (let i = 0; i < this.downloadableLoRAs.length; i++) { - const lora = this.downloadableLoRAs[i]; - - // Reset current LoRA progress for new download - currentLoraProgress = 0; - - // Initial status update for new LoRA - this.loadingManager.setStatus(`Starting download for LoRA ${i+1}/${this.downloadableLoRAs.length}`); - updateProgress(0, completedDownloads, lora.name); - - try { - // Download the LoRA - const response = await fetch('/api/download-lora', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - download_url: lora.downloadUrl, - model_version_id: lora.modelVersionId, - model_hash: lora.hash, - lora_root: loraRoot, - relative_path: targetPath.replace(loraRoot + '/', '') - }) - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error(`Failed to download LoRA ${lora.name}: ${errorText}`); - - // Check if this is an early access error (status 401 is the key indicator) - if (response.status === 401) { - accessFailures++; - this.loadingManager.setStatus( - `Failed to download ${lora.name}: Access restricted` - ); - } - - failedDownloads++; - // Continue with next download - } else { - completedDownloads++; - - // Update progress to show completion of current LoRA - updateProgress(100, completedDownloads, ''); - - if (completedDownloads + failedDownloads < this.downloadableLoRAs.length) { - this.loadingManager.setStatus( - `Completed ${completedDownloads}/${this.downloadableLoRAs.length} LoRAs. Starting next download...` - ); - } - } - } catch (downloadError) { - console.error(`Error downloading LoRA ${lora.name}:`, downloadError); - failedDownloads++; - // Continue with next download - } - } - - // Close WebSocket - ws.close(); - - // Show appropriate completion message based on results - if (failedDownloads === 0) { - showToast(`All ${completedDownloads} LoRAs downloaded successfully`, 'success'); - } else { - if (accessFailures > 0) { - showToast( - `Downloaded ${completedDownloads} of ${this.downloadableLoRAs.length} LoRAs. ${accessFailures} failed due to access restrictions. Check your API key in settings or early access status.`, - 'error' - ); - } else { - showToast(`Downloaded ${completedDownloads} of ${this.downloadableLoRAs.length} LoRAs`, 'error'); - } - } - } - - // Show success message - if (isDownloadOnly) { - if (failedDownloads === 0) { - showToast('LoRAs downloaded successfully', 'success'); - } - } else { - showToast(`Recipe "${this.recipeName}" saved successfully`, 'success'); - } - - // Close modal - modalManager.closeModal('importModal'); - - // Refresh the recipe - window.recipeManager.loadRecipes(this.recipeId); - - } catch (error) { - console.error('Error:', error); - showToast(error.message, 'error'); - } finally { - this.loadingManager.hide(); - } + await this.downloadManager.saveRecipe(); } - initializeFolderBrowser() { - const folderBrowser = document.getElementById('importFolderBrowser'); - if (!folderBrowser) return; - - // Cleanup existing handler if any - this.cleanupFolderBrowser(); - - // Create new handler - this.folderClickHandler = (event) => { - const folderItem = event.target.closest('.folder-item'); - if (!folderItem) return; - - if (folderItem.classList.contains('selected')) { - folderItem.classList.remove('selected'); - this.selectedFolder = ''; - } else { - folderBrowser.querySelectorAll('.folder-item').forEach(f => - f.classList.remove('selected')); - folderItem.classList.add('selected'); - this.selectedFolder = folderItem.dataset.folder; - } - - // Update path display after folder selection - this.updateTargetPath(); - }; - - // Add the new handler - folderBrowser.addEventListener('click', this.folderClickHandler); - - // Add event listeners for path updates - const loraRoot = document.getElementById('importLoraRoot'); - const newFolder = document.getElementById('importNewFolder'); - - if (loraRoot) loraRoot.addEventListener('change', this.updateTargetPath); - if (newFolder) newFolder.addEventListener('input', this.updateTargetPath); - - // Update initial path - this.updateTargetPath(); - } - - cleanupFolderBrowser() { - if (this.folderClickHandler) { - const folderBrowser = document.getElementById('importFolderBrowser'); - if (folderBrowser) { - folderBrowser.removeEventListener('click', this.folderClickHandler); - this.folderClickHandler = null; - } - } - - // Remove path update listeners - const loraRoot = document.getElementById('importLoraRoot'); - const newFolder = document.getElementById('importNewFolder'); - - if (loraRoot) loraRoot.removeEventListener('change', this.updateTargetPath); - if (newFolder) newFolder.removeEventListener('input', this.updateTargetPath); - } - updateTargetPath() { - const pathDisplay = document.getElementById('importTargetPathDisplay'); - if (!pathDisplay) return; - - const loraRoot = document.getElementById('importLoraRoot')?.value || ''; - const newFolder = document.getElementById('importNewFolder')?.value?.trim() || ''; - - let fullPath = loraRoot || 'Select a LoRA root directory'; - - if (loraRoot) { - if (this.selectedFolder) { - fullPath += '/' + this.selectedFolder; - } - if (newFolder) { - fullPath += '/' + newFolder; - } - } - - pathDisplay.innerHTML = `${fullPath}`; + this.folderBrowser.updateTargetPath(); } - showStep(stepId) { - - // First, remove any injected styles to prevent conflicts - this.removeInjectedStyles(); - - // Hide all steps first - document.querySelectorAll('.import-step').forEach(step => { - step.style.display = 'none'; - }); - - // Show target step with a monitoring mechanism - const targetStep = document.getElementById(stepId); - if (targetStep) { - // Use direct style setting - targetStep.style.display = 'block'; - - // For the locationStep specifically, we need additional measures - if (stepId === 'locationStep') { - // Create a more persistent style to override any potential conflicts - this.injectedStyles = document.createElement('style'); - this.injectedStyles.innerHTML = ` - #locationStep { - display: block !important; - opacity: 1 !important; - visibility: visible !important; - } - `; - document.head.appendChild(this.injectedStyles); - - // Force layout recalculation - targetStep.offsetHeight; - - // Set up a monitor to ensure the step remains visible - setTimeout(() => { - if (targetStep.style.display !== 'block') { - targetStep.style.display = 'block'; - } - - // Check dimensions again after a short delay - const newRect = targetStep.getBoundingClientRect(); - }, 50); - } - - // Scroll modal content to top - const modalContent = document.querySelector('#importModal .modal-content'); - if (modalContent) { - modalContent.scrollTop = 0; - } - } - } - - // Add a helper method to format file sizes - formatFileSize(bytes) { - if (!bytes || isNaN(bytes)) return ''; - - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i]; - } - - // Add this method to ensure the modal is fully visible and initialized ensureModalVisible() { const importModal = document.getElementById('importModal'); if (!importModal) { @@ -1294,32 +245,25 @@ export class ImportManager { return true; } - // Add new method to handle downloading missing LoRAs from a recipe downloadMissingLoras(recipeData, recipeId) { // Store the recipe data and ID this.recipeData = recipeData; this.recipeId = recipeId; - // Show the location step directly + // Show the modal and go to location step this.showImportModal(recipeData, recipeId); this.proceedToLocation(); - // Update the modal title to reflect we're downloading for an existing recipe + // Update the modal title const modalTitle = document.querySelector('#importModal h2'); - if (modalTitle) { - modalTitle.textContent = 'Download Missing LoRAs'; - } + if (modalTitle) modalTitle.textContent = 'Download Missing LoRAs'; // Update the save button text const saveButton = document.querySelector('#locationStep .primary-btn'); - if (saveButton) { - saveButton.textContent = 'Download Missing LoRAs'; - } + if (saveButton) saveButton.textContent = 'Download Missing LoRAs'; - // Hide the back button since we're skipping steps + // Hide the back button const backButton = document.querySelector('#locationStep .secondary-btn'); - if (backButton) { - backButton.style.display = 'none'; - } + if (backButton) backButton.style.display = 'none'; } } diff --git a/static/js/managers/import/DownloadManager.js b/static/js/managers/import/DownloadManager.js new file mode 100644 index 00000000..058ba9cb --- /dev/null +++ b/static/js/managers/import/DownloadManager.js @@ -0,0 +1,257 @@ +import { showToast } from '../../utils/uiHelpers.js'; + +export class DownloadManager { + constructor(importManager) { + this.importManager = importManager; + } + + async saveRecipe() { + // Check if we're in download-only mode (for existing recipe) + const isDownloadOnly = !!this.importManager.recipeId; + + if (!isDownloadOnly && !this.importManager.recipeName) { + showToast('Please enter a recipe name', 'error'); + return; + } + + try { + // Show progress indicator + this.importManager.loadingManager.showSimpleLoading(isDownloadOnly ? 'Downloading LoRAs...' : 'Saving recipe...'); + + // Only send the complete recipe to save if not in download-only mode + if (!isDownloadOnly) { + // Create FormData object for saving recipe + const formData = new FormData(); + + // Add image data - depends on import mode + if (this.importManager.recipeImage) { + // Direct upload + formData.append('image', this.importManager.recipeImage); + } else if (this.importManager.recipeData && this.importManager.recipeData.image_base64) { + // URL mode with base64 data + formData.append('image_base64', this.importManager.recipeData.image_base64); + } else if (this.importManager.importMode === 'url') { + // Fallback for URL mode - tell backend to fetch the image again + const urlInput = document.getElementById('imageUrlInput'); + if (urlInput && urlInput.value) { + formData.append('image_url', urlInput.value); + } else { + throw new Error('No image data available'); + } + } else { + throw new Error('No image data available'); + } + + formData.append('name', this.importManager.recipeName); + formData.append('tags', JSON.stringify(this.importManager.recipeTags)); + + // Prepare complete metadata including generation parameters + const completeMetadata = { + base_model: this.importManager.recipeData.base_model || "", + loras: this.importManager.recipeData.loras || [], + gen_params: this.importManager.recipeData.gen_params || {}, + raw_metadata: this.importManager.recipeData.raw_metadata || {} + }; + + // Add source_path to metadata to track where the recipe was imported from + if (this.importManager.importMode === 'url') { + const urlInput = document.getElementById('imageUrlInput'); + console.log("urlInput.value", urlInput.value); + if (urlInput && urlInput.value) { + completeMetadata.source_path = urlInput.value; + } + } + + formData.append('metadata', JSON.stringify(completeMetadata)); + + // Send save request + const response = await fetch('/api/recipes/save', { + method: 'POST', + body: formData + }); + + const result = await response.json(); + + if (!result.success) { + // Handle save error + console.error("Failed to save recipe:", result.error); + showToast(result.error, 'error'); + // Close modal + modalManager.closeModal('importModal'); + return; + } + } + + // Check if we need to download LoRAs + let failedDownloads = 0; + if (this.importManager.downloadableLoRAs && this.importManager.downloadableLoRAs.length > 0) { + await this.downloadMissingLoras(); + } + + // Show success message + if (isDownloadOnly) { + if (failedDownloads === 0) { + showToast('LoRAs downloaded successfully', 'success'); + } + } else { + showToast(`Recipe "${this.importManager.recipeName}" saved successfully`, 'success'); + } + + // Close modal + modalManager.closeModal('importModal'); + + // Refresh the recipe + window.recipeManager.loadRecipes(); + + } catch (error) { + console.error('Error:', error); + showToast(error.message, 'error'); + } finally { + this.importManager.loadingManager.hide(); + } + } + + async downloadMissingLoras() { + // For download, we need to validate the target path + const loraRoot = document.getElementById('importLoraRoot')?.value; + if (!loraRoot) { + throw new Error('Please select a LoRA root directory'); + } + + // Build target path + let targetPath = loraRoot; + if (this.importManager.selectedFolder) { + targetPath += '/' + this.importManager.selectedFolder; + } + + const newFolder = document.getElementById('importNewFolder')?.value?.trim(); + if (newFolder) { + targetPath += '/' + newFolder; + } + + // Set up WebSocket for progress updates + const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; + const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`); + + // Show enhanced loading with progress details for multiple items + const updateProgress = this.importManager.loadingManager.showDownloadProgress( + this.importManager.downloadableLoRAs.length + ); + + let completedDownloads = 0; + let failedDownloads = 0; + let accessFailures = 0; + let currentLoraProgress = 0; + + // Set up progress tracking for current download + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.status === 'progress') { + // Update current LoRA progress + currentLoraProgress = data.progress; + + // Get current LoRA name + const currentLora = this.importManager.downloadableLoRAs[completedDownloads + failedDownloads]; + const loraName = currentLora ? currentLora.name : ''; + + // Update progress display + updateProgress(currentLoraProgress, completedDownloads, loraName); + + // Add more detailed status messages based on progress + if (currentLoraProgress < 3) { + this.importManager.loadingManager.setStatus( + `Preparing download for LoRA ${completedDownloads + failedDownloads + 1}/${this.importManager.downloadableLoRAs.length}` + ); + } else if (currentLoraProgress === 3) { + this.importManager.loadingManager.setStatus( + `Downloaded preview for LoRA ${completedDownloads + failedDownloads + 1}/${this.importManager.downloadableLoRAs.length}` + ); + } else if (currentLoraProgress > 3 && currentLoraProgress < 100) { + this.importManager.loadingManager.setStatus( + `Downloading LoRA ${completedDownloads + failedDownloads + 1}/${this.importManager.downloadableLoRAs.length}` + ); + } else { + this.importManager.loadingManager.setStatus( + `Finalizing LoRA ${completedDownloads + failedDownloads + 1}/${this.importManager.downloadableLoRAs.length}` + ); + } + } + }; + + for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) { + const lora = this.importManager.downloadableLoRAs[i]; + + // Reset current LoRA progress for new download + currentLoraProgress = 0; + + // Initial status update for new LoRA + this.importManager.loadingManager.setStatus(`Starting download for LoRA ${i+1}/${this.importManager.downloadableLoRAs.length}`); + updateProgress(0, completedDownloads, lora.name); + + try { + // Download the LoRA + const response = await fetch('/api/download-lora', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + download_url: lora.downloadUrl, + model_version_id: lora.modelVersionId, + model_hash: lora.hash, + lora_root: loraRoot, + relative_path: targetPath.replace(loraRoot + '/', '') + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`Failed to download LoRA ${lora.name}: ${errorText}`); + + // Check if this is an early access error (status 401 is the key indicator) + if (response.status === 401) { + accessFailures++; + this.importManager.loadingManager.setStatus( + `Failed to download ${lora.name}: Access restricted` + ); + } + + failedDownloads++; + // Continue with next download + } else { + completedDownloads++; + + // Update progress to show completion of current LoRA + updateProgress(100, completedDownloads, ''); + + if (completedDownloads + failedDownloads < this.importManager.downloadableLoRAs.length) { + this.importManager.loadingManager.setStatus( + `Completed ${completedDownloads}/${this.importManager.downloadableLoRAs.length} LoRAs. Starting next download...` + ); + } + } + } catch (downloadError) { + console.error(`Error downloading LoRA ${lora.name}:`, downloadError); + failedDownloads++; + // Continue with next download + } + } + + // Close WebSocket + ws.close(); + + // Show appropriate completion message based on results + if (failedDownloads === 0) { + showToast(`All ${completedDownloads} LoRAs downloaded successfully`, 'success'); + } else { + if (accessFailures > 0) { + showToast( + `Downloaded ${completedDownloads} of ${this.importManager.downloadableLoRAs.length} LoRAs. ${accessFailures} failed due to access restrictions. Check your API key in settings or early access status.`, + 'error' + ); + } else { + showToast(`Downloaded ${completedDownloads} of ${this.importManager.downloadableLoRAs.length} LoRAs`, 'error'); + } + } + + return failedDownloads; + } +} diff --git a/static/js/managers/import/FolderBrowser.js b/static/js/managers/import/FolderBrowser.js new file mode 100644 index 00000000..33504ee8 --- /dev/null +++ b/static/js/managers/import/FolderBrowser.js @@ -0,0 +1,220 @@ +import { showToast } from '../../utils/uiHelpers.js'; +import { getStorageItem } from '../../utils/storageHelpers.js'; + +export class FolderBrowser { + constructor(importManager) { + this.importManager = importManager; + this.folderClickHandler = null; + this.updateTargetPath = this.updateTargetPath.bind(this); + } + + async proceedToLocation() { + // Show the location step with special handling + this.importManager.stepManager.showStep('locationStep'); + + // Double-check after a short delay to ensure the step is visible + setTimeout(() => { + const locationStep = document.getElementById('locationStep'); + if (locationStep.style.display !== 'block' || + window.getComputedStyle(locationStep).display !== 'block') { + // Force display again + locationStep.style.display = 'block'; + + // If still not visible, try with injected style + if (window.getComputedStyle(locationStep).display !== 'block') { + this.importManager.stepManager.injectedStyles = document.createElement('style'); + this.importManager.stepManager.injectedStyles.innerHTML = ` + #locationStep { + display: block !important; + opacity: 1 !important; + visibility: visible !important; + } + `; + document.head.appendChild(this.importManager.stepManager.injectedStyles); + } + } + }, 100); + + try { + // Display missing LoRAs that will be downloaded + const missingLorasList = document.getElementById('missingLorasList'); + if (missingLorasList && this.importManager.downloadableLoRAs.length > 0) { + // Calculate total size + const totalSize = this.importManager.downloadableLoRAs.reduce((sum, lora) => { + return sum + (lora.size ? parseInt(lora.size) : 0); + }, 0); + + // Update total size display + const totalSizeDisplay = document.getElementById('totalDownloadSize'); + if (totalSizeDisplay) { + totalSizeDisplay.textContent = this.importManager.formatFileSize(totalSize); + } + + // Update header to include count of missing LoRAs + const missingLorasHeader = document.querySelector('.summary-header h3'); + if (missingLorasHeader) { + missingLorasHeader.innerHTML = `Missing LoRAs (${this.importManager.downloadableLoRAs.length}) ${this.importManager.formatFileSize(totalSize)}`; + } + + // Generate missing LoRAs list + missingLorasList.innerHTML = this.importManager.downloadableLoRAs.map(lora => { + const sizeDisplay = lora.size ? + this.importManager.formatFileSize(lora.size) : 'Unknown size'; + const baseModel = lora.baseModel ? + `${lora.baseModel}` : ''; + const isEarlyAccess = lora.isEarlyAccess; + + // Early access badge + let earlyAccessBadge = ''; + if (isEarlyAccess) { + earlyAccessBadge = ` + Early Access + `; + } + + return ` +
+
+
${lora.name}
+ ${baseModel} + ${earlyAccessBadge} +
+
${sizeDisplay}
+
+ `; + }).join(''); + + // Set up toggle for missing LoRAs list + const toggleBtn = document.getElementById('toggleMissingLorasList'); + if (toggleBtn) { + toggleBtn.addEventListener('click', () => { + missingLorasList.classList.toggle('collapsed'); + const icon = toggleBtn.querySelector('i'); + if (icon) { + icon.classList.toggle('fa-chevron-down'); + icon.classList.toggle('fa-chevron-up'); + } + }); + } + } + + // Fetch LoRA roots + const rootsResponse = await fetch('/api/lora-roots'); + if (!rootsResponse.ok) { + throw new Error(`Failed to fetch LoRA roots: ${rootsResponse.status}`); + } + + const rootsData = await rootsResponse.json(); + const loraRoot = document.getElementById('importLoraRoot'); + if (loraRoot) { + loraRoot.innerHTML = rootsData.roots.map(root => + `` + ).join(''); + + // Set default lora root if available + const defaultRoot = getStorageItem('settings', {}).default_loras_root; + if (defaultRoot && rootsData.roots.includes(defaultRoot)) { + loraRoot.value = defaultRoot; + } + } + + // Fetch folders + const foldersResponse = await fetch('/api/folders'); + if (!foldersResponse.ok) { + throw new Error(`Failed to fetch folders: ${foldersResponse.status}`); + } + + const foldersData = await foldersResponse.json(); + const folderBrowser = document.getElementById('importFolderBrowser'); + if (folderBrowser) { + folderBrowser.innerHTML = foldersData.folders.map(folder => + folder ? `
${folder}
` : '' + ).join(''); + } + + // Initialize folder browser after loading data + this.initializeFolderBrowser(); + } catch (error) { + console.error('Error in API calls:', error); + showToast(error.message, 'error'); + } + } + + initializeFolderBrowser() { + const folderBrowser = document.getElementById('importFolderBrowser'); + if (!folderBrowser) return; + + // Cleanup existing handler if any + this.cleanup(); + + // Create new handler + this.folderClickHandler = (event) => { + const folderItem = event.target.closest('.folder-item'); + if (!folderItem) return; + + if (folderItem.classList.contains('selected')) { + folderItem.classList.remove('selected'); + this.importManager.selectedFolder = ''; + } else { + folderBrowser.querySelectorAll('.folder-item').forEach(f => + f.classList.remove('selected')); + folderItem.classList.add('selected'); + this.importManager.selectedFolder = folderItem.dataset.folder; + } + + // Update path display after folder selection + this.updateTargetPath(); + }; + + // Add the new handler + folderBrowser.addEventListener('click', this.folderClickHandler); + + // Add event listeners for path updates + const loraRoot = document.getElementById('importLoraRoot'); + const newFolder = document.getElementById('importNewFolder'); + + if (loraRoot) loraRoot.addEventListener('change', this.updateTargetPath); + if (newFolder) newFolder.addEventListener('input', this.updateTargetPath); + + // Update initial path + this.updateTargetPath(); + } + + cleanup() { + if (this.folderClickHandler) { + const folderBrowser = document.getElementById('importFolderBrowser'); + if (folderBrowser) { + folderBrowser.removeEventListener('click', this.folderClickHandler); + this.folderClickHandler = null; + } + } + + // Remove path update listeners + const loraRoot = document.getElementById('importLoraRoot'); + const newFolder = document.getElementById('importNewFolder'); + + if (loraRoot) loraRoot.removeEventListener('change', this.updateTargetPath); + if (newFolder) newFolder.removeEventListener('input', this.updateTargetPath); + } + + updateTargetPath() { + const pathDisplay = document.getElementById('importTargetPathDisplay'); + if (!pathDisplay) return; + + const loraRoot = document.getElementById('importLoraRoot')?.value || ''; + const newFolder = document.getElementById('importNewFolder')?.value?.trim() || ''; + + let fullPath = loraRoot || 'Select a LoRA root directory'; + + if (loraRoot) { + if (this.importManager.selectedFolder) { + fullPath += '/' + this.importManager.selectedFolder; + } + if (newFolder) { + fullPath += '/' + newFolder; + } + } + + pathDisplay.innerHTML = `${fullPath}`; + } +} diff --git a/static/js/managers/import/ImageProcessor.js b/static/js/managers/import/ImageProcessor.js new file mode 100644 index 00000000..be568d50 --- /dev/null +++ b/static/js/managers/import/ImageProcessor.js @@ -0,0 +1,206 @@ +import { showToast } from '../../utils/uiHelpers.js'; + +export class ImageProcessor { + constructor(importManager) { + this.importManager = importManager; + } + + handleFileUpload(event) { + const file = event.target.files[0]; + const errorElement = document.getElementById('uploadError'); + + if (!file) return; + + // Validate file type + if (!file.type.match('image.*')) { + errorElement.textContent = 'Please select an image file'; + return; + } + + // Reset error + errorElement.textContent = ''; + this.importManager.recipeImage = file; + + // Auto-proceed to next step if file is selected + this.importManager.uploadAndAnalyzeImage(); + } + + async handleUrlInput() { + const urlInput = document.getElementById('imageUrlInput'); + const errorElement = document.getElementById('urlError'); + const input = urlInput.value.trim(); + + // Validate input + if (!input) { + errorElement.textContent = 'Please enter a URL or file path'; + return; + } + + // Reset error + errorElement.textContent = ''; + + // Show loading indicator + this.importManager.loadingManager.showSimpleLoading('Processing input...'); + + try { + // Check if it's a URL or a local file path + if (input.startsWith('http://') || input.startsWith('https://')) { + // Handle as URL + await this.analyzeImageFromUrl(input); + } else { + // Handle as local file path + await this.analyzeImageFromLocalPath(input); + } + } catch (error) { + errorElement.textContent = error.message || 'Failed to process input'; + } finally { + this.importManager.loadingManager.hide(); + } + } + + async analyzeImageFromUrl(url) { + try { + // Call the API with URL data + const response = await fetch('/api/recipes/analyze-image', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ url: url }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to analyze image from URL'); + } + + // Get recipe data from response + this.importManager.recipeData = await response.json(); + + // Check if we have an error message + if (this.importManager.recipeData.error) { + throw new Error(this.importManager.recipeData.error); + } + + // Check if we have valid recipe data + if (!this.importManager.recipeData || + !this.importManager.recipeData.loras || + this.importManager.recipeData.loras.length === 0) { + throw new Error('No LoRA information found in this image'); + } + + // Find missing LoRAs + this.importManager.missingLoras = this.importManager.recipeData.loras.filter( + lora => !lora.existsLocally + ); + + // Proceed to recipe details step + this.importManager.showRecipeDetailsStep(); + + } catch (error) { + console.error('Error analyzing URL:', error); + throw error; + } + } + + async analyzeImageFromLocalPath(path) { + try { + // Call the API with local path data + const response = await fetch('/api/recipes/analyze-local-image', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ path: path }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to load image from local path'); + } + + // Get recipe data from response + this.importManager.recipeData = await response.json(); + + // Check if we have an error message + if (this.importManager.recipeData.error) { + throw new Error(this.importManager.recipeData.error); + } + + // Check if we have valid recipe data + if (!this.importManager.recipeData || + !this.importManager.recipeData.loras || + this.importManager.recipeData.loras.length === 0) { + throw new Error('No LoRA information found in this image'); + } + + // Find missing LoRAs + this.importManager.missingLoras = this.importManager.recipeData.loras.filter( + lora => !lora.existsLocally + ); + + // Proceed to recipe details step + this.importManager.showRecipeDetailsStep(); + + } catch (error) { + console.error('Error analyzing local path:', error); + throw error; + } + } + + async uploadAndAnalyzeImage() { + if (!this.importManager.recipeImage) { + showToast('Please select an image first', 'error'); + return; + } + + try { + this.importManager.loadingManager.showSimpleLoading('Analyzing image metadata...'); + + // Create form data for upload + const formData = new FormData(); + formData.append('image', this.importManager.recipeImage); + + // Upload image for analysis + const response = await fetch('/api/recipes/analyze-image', { + method: 'POST', + body: formData + }); + + // Get recipe data from response + this.importManager.recipeData = await response.json(); + + console.log('Recipe data:', this.importManager.recipeData); + + // Check if we have an error message + if (this.importManager.recipeData.error) { + throw new Error(this.importManager.recipeData.error); + } + + // Check if we have valid recipe data + if (!this.importManager.recipeData || + !this.importManager.recipeData.loras || + this.importManager.recipeData.loras.length === 0) { + throw new Error('No LoRA information found in this image'); + } + + // Store generation parameters if available + if (this.importManager.recipeData.gen_params) { + console.log('Generation parameters found:', this.importManager.recipeData.gen_params); + } + + // Find missing LoRAs + this.importManager.missingLoras = this.importManager.recipeData.loras.filter( + lora => !lora.existsLocally + ); + + // Proceed to recipe details step + this.importManager.showRecipeDetailsStep(); + + } catch (error) { + document.getElementById('uploadError').textContent = error.message; + } finally { + this.importManager.loadingManager.hide(); + } + } +} diff --git a/static/js/managers/import/ImportStepManager.js b/static/js/managers/import/ImportStepManager.js new file mode 100644 index 00000000..80ecfbb9 --- /dev/null +++ b/static/js/managers/import/ImportStepManager.js @@ -0,0 +1,57 @@ +export class ImportStepManager { + constructor() { + this.injectedStyles = null; + } + + removeInjectedStyles() { + if (this.injectedStyles && this.injectedStyles.parentNode) { + this.injectedStyles.parentNode.removeChild(this.injectedStyles); + this.injectedStyles = null; + } + + // Reset inline styles + document.querySelectorAll('.import-step').forEach(step => { + step.style.cssText = ''; + }); + } + + showStep(stepId) { + // Remove any injected styles to prevent conflicts + this.removeInjectedStyles(); + + // Hide all steps first + document.querySelectorAll('.import-step').forEach(step => { + step.style.display = 'none'; + }); + + // Show target step with a monitoring mechanism + const targetStep = document.getElementById(stepId); + if (targetStep) { + // Use direct style setting + targetStep.style.display = 'block'; + + // For the locationStep specifically, we need additional measures + if (stepId === 'locationStep') { + // Create a more persistent style to override any potential conflicts + this.injectedStyles = document.createElement('style'); + this.injectedStyles.innerHTML = ` + #locationStep { + display: block !important; + opacity: 1 !important; + visibility: visible !important; + } + `; + document.head.appendChild(this.injectedStyles); + + // Force layout recalculation + targetStep.offsetHeight; + } + + // Scroll modal content to top + const modalContent = document.querySelector('#importModal .modal-content'); + if (modalContent) { + modalContent.scrollTop = 0; + } + } + } +} diff --git a/static/js/managers/import/RecipeDataManager.js b/static/js/managers/import/RecipeDataManager.js new file mode 100644 index 00000000..da25538c --- /dev/null +++ b/static/js/managers/import/RecipeDataManager.js @@ -0,0 +1,349 @@ +import { showToast } from '../../utils/uiHelpers.js'; + +export class RecipeDataManager { + constructor(importManager) { + this.importManager = importManager; + } + + showRecipeDetailsStep() { + this.importManager.stepManager.showStep('detailsStep'); + + // Set default recipe name from prompt or image filename + const recipeName = document.getElementById('recipeName'); + + // Check if we have recipe metadata from a shared recipe + if (this.importManager.recipeData && this.importManager.recipeData.from_recipe_metadata) { + // Use title from recipe metadata + if (this.importManager.recipeData.title) { + recipeName.value = this.importManager.recipeData.title; + this.importManager.recipeName = this.importManager.recipeData.title; + } + + // Use tags from recipe metadata + if (this.importManager.recipeData.tags && Array.isArray(this.importManager.recipeData.tags)) { + this.importManager.recipeTags = [...this.importManager.recipeData.tags]; + this.updateTagsDisplay(); + } + } else if (this.importManager.recipeData && + this.importManager.recipeData.gen_params && + this.importManager.recipeData.gen_params.prompt) { + // Use the first 10 words from the prompt as the default recipe name + const promptWords = this.importManager.recipeData.gen_params.prompt.split(' '); + const truncatedPrompt = promptWords.slice(0, 10).join(' '); + recipeName.value = truncatedPrompt; + this.importManager.recipeName = truncatedPrompt; + + // Set up click handler to select all text for easy editing + if (!recipeName.hasSelectAllHandler) { + recipeName.addEventListener('click', function() { + this.select(); + }); + recipeName.hasSelectAllHandler = true; + } + } else if (this.importManager.recipeImage && !recipeName.value) { + // Fallback to image filename if no prompt is available + const fileName = this.importManager.recipeImage.name.split('.')[0]; + recipeName.value = fileName; + this.importManager.recipeName = fileName; + } + + // Always set up click handler for easy editing if not already set + if (!recipeName.hasSelectAllHandler) { + recipeName.addEventListener('click', function() { + this.select(); + }); + recipeName.hasSelectAllHandler = true; + } + + // Display the uploaded image in the preview + const imagePreview = document.getElementById('recipeImagePreview'); + if (imagePreview) { + if (this.importManager.recipeImage) { + // For file upload mode + const reader = new FileReader(); + reader.onload = (e) => { + imagePreview.innerHTML = `Recipe preview`; + }; + reader.readAsDataURL(this.importManager.recipeImage); + } else if (this.importManager.recipeData && this.importManager.recipeData.image_base64) { + // For URL mode - use the base64 image data returned from the backend + imagePreview.innerHTML = `Recipe preview`; + } else if (this.importManager.importMode === 'url') { + // Fallback for URL mode if no base64 data + const urlInput = document.getElementById('imageUrlInput'); + if (urlInput && urlInput.value) { + imagePreview.innerHTML = `Recipe preview`; + } + } + } + + // Update LoRA count information + const totalLoras = this.importManager.recipeData.loras.length; + const existingLoras = this.importManager.recipeData.loras.filter(lora => lora.existsLocally).length; + const loraCountInfo = document.getElementById('loraCountInfo'); + if (loraCountInfo) { + loraCountInfo.textContent = `(${existingLoras}/${totalLoras} in library)`; + } + + // Display LoRAs list + const lorasList = document.getElementById('lorasList'); + if (lorasList) { + lorasList.innerHTML = this.importManager.recipeData.loras.map(lora => { + const existsLocally = lora.existsLocally; + const isDeleted = lora.isDeleted; + const isEarlyAccess = lora.isEarlyAccess; + const localPath = lora.localPath || ''; + + // Create status badge based on LoRA status + let statusBadge; + if (isDeleted) { + statusBadge = `
+ Deleted from Civitai +
`; + } else { + statusBadge = existsLocally ? + `
+ In Library +
${localPath}
+
` : + `
+ Not in Library +
`; + } + + // Early access badge (shown additionally with other badges) + let earlyAccessBadge = ''; + if (isEarlyAccess) { + // Format the early access end date if available + let earlyAccessInfo = 'This LoRA requires early access payment to download.'; + if (lora.earlyAccessEndsAt) { + try { + const endDate = new Date(lora.earlyAccessEndsAt); + const formattedDate = endDate.toLocaleDateString(); + earlyAccessInfo += ` Early access ends on ${formattedDate}.`; + } catch (e) { + console.warn('Failed to format early access date', e); + } + } + + earlyAccessBadge = `
+ Early Access +
${earlyAccessInfo} Verify that you have purchased early access before downloading.
+
`; + } + + // Format size if available + const sizeDisplay = lora.size ? + `
${this.importManager.formatFileSize(lora.size)}
` : ''; + + return ` +
+
+ LoRA preview +
+
+
+

${lora.name}

+
+ ${statusBadge} + ${earlyAccessBadge} +
+
+ ${lora.version ? `
${lora.version}
` : ''} +
+ ${lora.baseModel ? `
${lora.baseModel}
` : ''} + ${sizeDisplay} +
Weight: ${lora.weight || 1.0}
+
+
+
+ `; + }).join(''); + } + + // Check for early access loras and show warning if any exist + const earlyAccessLoras = this.importManager.recipeData.loras.filter(lora => + lora.isEarlyAccess && !lora.existsLocally && !lora.isDeleted); + if (earlyAccessLoras.length > 0) { + // Show a warning about early access loras + const warningMessage = ` +
+
+
+
${earlyAccessLoras.length} LoRA(s) require Early Access
+
+ These LoRAs require a payment to access. Download will fail if you haven't purchased access. + You may need to log in to your Civitai account in browser settings. +
+
+
+ `; + + // Show the warning message + const buttonsContainer = document.querySelector('#detailsStep .modal-actions'); + if (buttonsContainer) { + // Remove existing warning if any + const existingWarning = document.getElementById('earlyAccessWarning'); + if (existingWarning) { + existingWarning.remove(); + } + + // Add new warning + const warningContainer = document.createElement('div'); + warningContainer.id = 'earlyAccessWarning'; + warningContainer.innerHTML = warningMessage; + buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); + } + } + + // Update Next button state based on missing LoRAs + this.updateNextButtonState(); + } + + updateNextButtonState() { + const nextButton = document.querySelector('#detailsStep .primary-btn'); + if (!nextButton) return; + + // Always clean up previous warnings first + const existingWarning = document.getElementById('deletedLorasWarning'); + if (existingWarning) { + existingWarning.remove(); + } + + // Count deleted LoRAs + const deletedLoras = this.importManager.recipeData.loras.filter(lora => lora.isDeleted).length; + + // If we have deleted LoRAs, show a warning and update button text + if (deletedLoras > 0) { + // Create a new warning container above the buttons + const buttonsContainer = document.querySelector('#detailsStep .modal-actions') || nextButton.parentNode; + const warningContainer = document.createElement('div'); + warningContainer.id = 'deletedLorasWarning'; + warningContainer.className = 'deleted-loras-warning'; + + // Create warning message + warningContainer.innerHTML = ` +
+
+
${deletedLoras} LoRA(s) have been deleted from Civitai
+
These LoRAs cannot be downloaded. If you continue, they will remain in the recipe but won't be included when used.
+
+ `; + + // Insert before the buttons container + buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); + } + + // If we have missing LoRAs (not deleted), show "Download Missing LoRAs" + // Otherwise show "Save Recipe" + const missingNotDeleted = this.importManager.recipeData.loras.filter( + lora => !lora.existsLocally && !lora.isDeleted + ).length; + + if (missingNotDeleted > 0) { + nextButton.textContent = 'Download Missing LoRAs'; + } else { + nextButton.textContent = 'Save Recipe'; + } + } + + addTag() { + const tagInput = document.getElementById('tagInput'); + const tag = tagInput.value.trim(); + + if (!tag) return; + + if (!this.importManager.recipeTags.includes(tag)) { + this.importManager.recipeTags.push(tag); + this.updateTagsDisplay(); + } + + tagInput.value = ''; + } + + removeTag(tag) { + this.importManager.recipeTags = this.importManager.recipeTags.filter(t => t !== tag); + this.updateTagsDisplay(); + } + + updateTagsDisplay() { + const tagsContainer = document.getElementById('tagsContainer'); + + if (this.importManager.recipeTags.length === 0) { + tagsContainer.innerHTML = '
No tags added
'; + return; + } + + tagsContainer.innerHTML = this.importManager.recipeTags.map(tag => ` +
+ ${tag} + +
+ `).join(''); + } + + proceedFromDetails() { + // Validate recipe name + if (!this.importManager.recipeName) { + showToast('Please enter a recipe name', 'error'); + return; + } + + // Automatically mark all deleted LoRAs as excluded + if (this.importManager.recipeData && this.importManager.recipeData.loras) { + this.importManager.recipeData.loras.forEach(lora => { + if (lora.isDeleted) { + lora.exclude = true; + } + }); + } + + // Update missing LoRAs list to exclude deleted LoRAs + this.importManager.missingLoras = this.importManager.recipeData.loras.filter(lora => + !lora.existsLocally && !lora.isDeleted); + + // Check for early access loras and show warning if any exist + const earlyAccessLoras = this.importManager.missingLoras.filter(lora => lora.isEarlyAccess); + if (earlyAccessLoras.length > 0) { + // Show a warning about early access loras + const warningMessage = ` +
+
+
+
${earlyAccessLoras.length} LoRA(s) require Early Access
+
+ These LoRAs require a payment to access. Download will fail if you haven't purchased access. + You may need to log in to your Civitai account in browser settings. +
+
+
+ `; + + // Show the warning message + const buttonsContainer = document.querySelector('#detailsStep .modal-actions'); + if (buttonsContainer) { + // Remove existing warning if any + const existingWarning = document.getElementById('earlyAccessWarning'); + if (existingWarning) { + existingWarning.remove(); + } + + // Add new warning + const warningContainer = document.createElement('div'); + warningContainer.id = 'earlyAccessWarning'; + warningContainer.innerHTML = warningMessage; + buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); + } + } + + // If we have downloadable missing LoRAs, go to location step + if (this.importManager.missingLoras.length > 0) { + // Store only downloadable LoRAs for the download step + this.importManager.downloadableLoRAs = this.importManager.missingLoras; + this.importManager.proceedToLocation(); + } else { + // Otherwise, save the recipe directly + this.importManager.saveRecipe(); + } + } +} diff --git a/static/js/utils/formatters.js b/static/js/utils/formatters.js new file mode 100644 index 00000000..00c17213 --- /dev/null +++ b/static/js/utils/formatters.js @@ -0,0 +1,12 @@ +/** + * Format a file size in bytes to a human-readable string + * @param {number} bytes - The size in bytes + * @returns {string} Formatted size string (e.g., "1.5 MB") + */ +export function formatFileSize(bytes) { + if (!bytes || isNaN(bytes)) return ''; + + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i]; +}