diff --git a/static/css/components/import-modal.css b/static/css/components/import-modal.css index 4349c749..112c5d60 100644 --- a/static/css/components/import-modal.css +++ b/static/css/components/import-modal.css @@ -733,3 +733,205 @@ font-size: 0.9em; line-height: 1.4; } + +/* Duplicate Recipes Styles */ +.duplicate-recipes-container { + margin-bottom: var(--space-3); + border-radius: var(--border-radius-sm); + overflow: hidden; + animation: fadeIn 0.3s ease-in-out; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + +.duplicate-warning { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + background: oklch(var(--lora-accent) / 0.1); + border: 1px solid var(--lora-accent); + border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0; + color: var(--text-color); +} + +.duplicate-warning .warning-icon { + color: var(--lora-accent); + font-size: 1.2em; + padding-top: 2px; +} + +.duplicate-warning .warning-content { + flex: 1; +} + +.duplicate-warning .warning-title { + font-weight: 600; + margin-bottom: 4px; +} + +.duplicate-warning .warning-text { + font-size: 0.9em; + line-height: 1.4; +} + +.duplicate-recipes-list { + max-height: 250px; + overflow-y: auto; + border: 1px solid var(--border-color); + border-top: none; + border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm); + background: var(--bg-color); +} + +.duplicate-recipe-item { + display: flex; + gap: var(--space-2); + padding: var(--space-2); + border-bottom: 1px solid var(--border-color); + position: relative; + transition: background-color 0.2s; +} + +.duplicate-recipe-item:last-child { + border-bottom: none; +} + +.duplicate-recipe-item:hover { + background: var(--lora-surface); +} + +.duplicate-recipe-item.marked-for-deletion { + background: rgba(var(--lora-error-rgb), 0.1); + opacity: 0.8; +} + +.duplicate-recipe-item.marked-for-deletion::before { + content: 'Will be deleted'; + position: absolute; + right: 10px; + top: 5px; + background: var(--lora-error); + color: white; + padding: 2px 8px; + border-radius: var(--border-radius-xs); + font-size: 0.8em; +} + +.duplicate-recipe-preview { + width: 80px; + height: 80px; + flex-shrink: 0; + border-radius: var(--border-radius-xs); + overflow: hidden; + background: var(--bg-color); + border: 1px solid var(--border-color); +} + +.duplicate-recipe-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.duplicate-recipe-content { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-width: 0; +} + +.duplicate-recipe-title { + font-weight: 500; + font-size: 1em; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.duplicate-recipe-info { + display: flex; + gap: 12px; + font-size: 0.85em; + color: var(--text-color); + opacity: 0.8; +} + +.duplicate-recipe-date, +.duplicate-recipe-lora-count { + display: flex; + align-items: center; + gap: 4px; +} + +.duplicate-recipe-actions { + display: flex; + justify-content: center; + align-items: center; + min-width: 80px; +} + +.duplicate-recipe-actions button { + padding: 6px 12px; + font-size: 0.85em; + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; + width: 100%; + justify-content: center; +} + +.danger-btn { + background: var(--lora-error) !important; + color: white !important; + border: none !important; +} + +.danger-btn:hover { + background: oklch(from var(--lora-error) l c h / 0.9) !important; +} + +.view-recipe-btn { + background: var(--lora-surface) !important; + color: var(--text-color) !important; + border: 1px solid var(--border-color) !important; +} + +.view-recipe-btn:hover { + background: var(--lora-accent) !important; + color: var(--lora-text) !important; +} + +.warning-btn { + background: var(--lora-warning) !important; + color: white !important; +} + +.warning-btn:hover { + background: oklch(from var(--lora-warning) l c h / 0.9) !important; +} + +/* Modal buttons layout to accommodate multiple buttons */ +.modal-actions { + display: flex; + justify-content: space-between; + gap: 10px; + margin-top: var(--space-3); +} + +.modal-actions button { + flex: 1; + white-space: nowrap; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} diff --git a/static/js/managers/ImportManager.js b/static/js/managers/ImportManager.js index bec00d73..5e3d80d0 100644 --- a/static/js/managers/ImportManager.js +++ b/static/js/managers/ImportManager.js @@ -245,6 +245,48 @@ export class ImportManager { return true; } + /** + * Marks or unmarks a duplicate recipe for deletion + * @param {string} recipeId - The ID of the recipe to mark/unmark + * @param {HTMLElement} buttonElement - The button element that was clicked + */ + markDuplicateForDeletion(recipeId, buttonElement) { + // Initialize recipesToDelete array if it doesn't exist + if (!this.recipesToDelete) { + this.recipesToDelete = []; + } + + // Get the recipe item container + const recipeItem = buttonElement.closest('.duplicate-recipe-item'); + if (!recipeItem) return; + + // Check if this recipe is already marked for deletion + const isMarked = this.recipesToDelete.includes(recipeId); + + if (isMarked) { + // Unmark the recipe + this.recipesToDelete = this.recipesToDelete.filter(id => id !== recipeId); + recipeItem.classList.remove('marked-for-deletion'); + buttonElement.innerHTML = ' Delete'; + } else { + // Mark the recipe for deletion + this.recipesToDelete.push(recipeId); + recipeItem.classList.add('marked-for-deletion'); + buttonElement.innerHTML = ' Keep'; + } + } + + /** + * Imports the recipe as new, ignoring duplicates + */ + importRecipeAnyway() { + // Set flag to indicate we're importing as a new recipe + this.importAsNew = true; + + // Proceed with normal flow but skip duplicate replacement + this.proceedFromDetails(); + } + downloadMissingLoras(recipeData, recipeId) { // Store the recipe data and ID this.recipeData = recipeData; diff --git a/static/js/managers/import/ImageProcessor.js b/static/js/managers/import/ImageProcessor.js index 5b316837..d428b720 100644 --- a/static/js/managers/import/ImageProcessor.js +++ b/static/js/managers/import/ImageProcessor.js @@ -94,6 +94,9 @@ export class ImageProcessor { lora => !lora.existsLocally ); + // Reset import as new flag + this.importManager.importAsNew = false; + // Proceed to recipe details step this.importManager.showRecipeDetailsStep(); @@ -139,6 +142,9 @@ export class ImageProcessor { lora => !lora.existsLocally ); + // Reset import as new flag + this.importManager.importAsNew = false; + // Proceed to recipe details step this.importManager.showRecipeDetailsStep(); @@ -187,6 +193,9 @@ export class ImageProcessor { lora => !lora.existsLocally ); + // Reset import as new flag + this.importManager.importAsNew = false; + // Proceed to recipe details step this.importManager.showRecipeDetailsStep(); diff --git a/static/js/managers/import/RecipeDataManager.js b/static/js/managers/import/RecipeDataManager.js index da25538c..0c4938c5 100644 --- a/static/js/managers/import/RecipeDataManager.js +++ b/static/js/managers/import/RecipeDataManager.js @@ -196,24 +196,134 @@ export class RecipeDataManager { } } - // Update Next button state based on missing LoRAs + // Check for duplicate recipes and display warning if found + this.checkAndDisplayDuplicates(); + + // Update Next button state based on missing LoRAs and duplicates this.updateNextButtonState(); } + checkAndDisplayDuplicates() { + // Check if we have duplicate recipes + if (this.importManager.recipeData && + this.importManager.recipeData.matching_recipes && + this.importManager.recipeData.matching_recipes.length > 0) { + + // Store duplicates in the importManager for later use + this.importManager.duplicateRecipes = this.importManager.recipeData.matching_recipes; + + // Create duplicate warning container + const duplicateContainer = document.getElementById('duplicateRecipesContainer') || + this.createDuplicateContainer(); + + // Format date helper function + const formatDate = (timestamp) => { + try { + const date = new Date(timestamp * 1000); + return date.toLocaleDateString() + ' ' + date.toLocaleTimeString(); + } catch (e) { + return 'Unknown date'; + } + }; + + // Generate the HTML for duplicate recipes + duplicateContainer.innerHTML = ` +
+
+
+
+ ${this.importManager.duplicateRecipes.length} similar ${this.importManager.duplicateRecipes.length === 1 ? 'recipe' : 'recipes'} found in your library +
+
+ These recipes contain the same LoRAs with similar weights. +
+
+
+
+ ${this.importManager.duplicateRecipes.map((recipe, index) => ` +
+
+ Recipe preview +
+
+
${recipe.title}
+
+
+ ${formatDate(recipe.modified)} +
+
+ ${recipe.lora_count} LoRAs +
+
+
+
+ +
+
+ `).join('')} +
+ `; + + // Show the duplicate container + duplicateContainer.style.display = 'block'; + + // Initialize deletion tracking if not already done + if (!this.importManager.recipesToDelete) { + this.importManager.recipesToDelete = []; + } + } else { + // No duplicates, hide the container if it exists + const duplicateContainer = document.getElementById('duplicateRecipesContainer'); + if (duplicateContainer) { + duplicateContainer.style.display = 'none'; + } + + // Reset deletion tracking + this.importManager.duplicateRecipes = []; + this.importManager.recipesToDelete = []; + } + } + + createDuplicateContainer() { + // Find where to insert the duplicate container + const lorasListContainer = document.querySelector('.input-group:has(#lorasList)'); + + if (!lorasListContainer) return null; + + // Create container + const duplicateContainer = document.createElement('div'); + duplicateContainer.id = 'duplicateRecipesContainer'; + duplicateContainer.className = 'duplicate-recipes-container'; + + // Insert before the LoRA list + lorasListContainer.parentNode.insertBefore(duplicateContainer, lorasListContainer); + + return duplicateContainer; + } + updateNextButtonState() { const nextButton = document.querySelector('#detailsStep .primary-btn'); - if (!nextButton) return; + const actionsContainer = document.querySelector('#detailsStep .modal-actions'); + if (!nextButton || !actionsContainer) return; - // Always clean up previous warnings first + // Always clean up previous warnings and buttons first const existingWarning = document.getElementById('deletedLorasWarning'); if (existingWarning) { existingWarning.remove(); } + // Remove any existing "import anyway" button + const importAnywayBtn = document.getElementById('importAnywayBtn'); + if (importAnywayBtn) { + importAnywayBtn.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 we have deleted LoRAs, show a warning if (deletedLoras > 0) { // Create a new warning container above the buttons const buttonsContainer = document.querySelector('#detailsStep .modal-actions') || nextButton.parentNode; @@ -233,17 +343,60 @@ export class RecipeDataManager { // Insert before the buttons container buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); } - + + // Check for duplicates to adjust button actions + const hasDuplicates = this.importManager.duplicateRecipes && + this.importManager.duplicateRecipes.length > 0; + // If we have missing LoRAs (not deleted), show "Download Missing LoRAs" - // Otherwise show "Save Recipe" + // Otherwise show appropriate action based on duplicates const missingNotDeleted = this.importManager.recipeData.loras.filter( lora => !lora.existsLocally && !lora.isDeleted ).length; - if (missingNotDeleted > 0) { - nextButton.textContent = 'Download Missing LoRAs'; + // All LoRAs exist locally + const allLorasExist = missingNotDeleted === 0; + + if (hasDuplicates) { + if (missingNotDeleted > 0) { + // We have both duplicates and missing LoRAs + nextButton.textContent = 'Download Missing LoRAs'; + + // Add "Import Anyway" as a secondary option + const importAnywayButton = document.createElement('button'); + importAnywayButton.id = 'importAnywayBtn'; + importAnywayButton.className = 'secondary-btn'; + importAnywayButton.innerHTML = ' Import as New Recipe'; + importAnywayButton.onclick = () => this.importManager.importRecipeAnyway(); + + // Insert after the back button + const backButton = document.querySelector('#detailsStep .secondary-btn'); + actionsContainer.insertBefore(importAnywayButton, backButton.nextSibling); + } else { + // All LoRAs exist locally, but we have duplicates + nextButton.textContent = 'Replace Existing Recipe'; + nextButton.classList.add('warning-btn'); + + // Add "Import as New" as a secondary option + const importAsNewButton = document.createElement('button'); + importAsNewButton.id = 'importAnywayBtn'; + importAsNewButton.className = 'secondary-btn'; + importAsNewButton.innerHTML = ' Import as New Recipe'; + importAsNewButton.onclick = () => this.importManager.importRecipeAnyway(); + + // Insert after the back button + const backButton = document.querySelector('#detailsStep .secondary-btn'); + actionsContainer.insertBefore(importAsNewButton, backButton.nextSibling); + } } else { - nextButton.textContent = 'Save Recipe'; + // No duplicates, standard behavior + nextButton.classList.remove('warning-btn'); + + if (missingNotDeleted > 0) { + nextButton.textContent = 'Download Missing LoRAs'; + } else { + nextButton.textContent = 'Save Recipe'; + } } } @@ -298,10 +451,24 @@ export class RecipeDataManager { }); } + // Process any recipes marked for deletion + if (this.importManager.recipesToDelete && this.importManager.recipesToDelete.length > 0) { + this.deleteMarkedRecipes(); + } + // Update missing LoRAs list to exclude deleted LoRAs this.importManager.missingLoras = this.importManager.recipeData.loras.filter(lora => !lora.existsLocally && !lora.isDeleted); - + + // Check if we're replacing a duplicate or proceeding normally + const hasDuplicates = this.importManager.duplicateRecipes && + this.importManager.duplicateRecipes.length > 0; + + // Store replacement flag in importManager + this.importManager.isReplacingDuplicate = hasDuplicates && + this.importManager.missingLoras.length === 0 && + !this.importManager.importAsNew; + // Check for early access loras and show warning if any exist const earlyAccessLoras = this.importManager.missingLoras.filter(lora => lora.isEarlyAccess); if (earlyAccessLoras.length > 0) { @@ -346,4 +513,46 @@ export class RecipeDataManager { this.importManager.saveRecipe(); } } + + async deleteMarkedRecipes() { + if (!this.importManager.recipesToDelete || this.importManager.recipesToDelete.length === 0) { + return; + } + + try { + // Show loading indicator + this.importManager.loadingManager.showSimpleLoading('Deleting marked recipes...'); + + // Call API to delete recipes + const response = await fetch('/api/recipes/bulk-delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + recipe_ids: this.importManager.recipesToDelete + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to delete recipes'); + } + + const result = await response.json(); + + // Show success message + const deletedCount = this.importManager.recipesToDelete.length; + showToast(`Successfully deleted ${deletedCount} ${deletedCount === 1 ? 'recipe' : 'recipes'}`, 'success'); + + // Reset the delete list + this.importManager.recipesToDelete = []; + + } catch (error) { + console.error('Error deleting recipes:', error); + showToast('Failed to delete recipes: ' + error.message, 'error'); + } finally { + this.importManager.loadingManager.hide(); + } + } } diff --git a/templates/components/import_modal.html b/templates/components/import_modal.html index 699494eb..7f9024e1 100644 --- a/templates/components/import_modal.html +++ b/templates/components/import_modal.html @@ -78,6 +78,11 @@ + + +