From 4034eb3221aa0bc445b48fe7aa5ba45c07560501 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Thu, 8 May 2025 17:29:58 +0800 Subject: [PATCH] feat: implement duplicate recipe detection and management; add UI for marking duplicates for deletion --- static/css/components/import-modal.css | 202 +++++++++++++++ static/js/managers/ImportManager.js | 42 ++++ static/js/managers/import/ImageProcessor.js | 9 + .../js/managers/import/RecipeDataManager.js | 229 +++++++++++++++++- templates/components/import_modal.html | 5 + 5 files changed, 477 insertions(+), 10 deletions(-) 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 = ` +