From 59aefdff77ec36a7ecc958f9da033eec6054e817 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Thu, 8 May 2025 13:05:12 +0800 Subject: [PATCH] feat: implement duplicate detection and management features; add UI components and styles for duplicates --- static/css/base.css | 4 +- static/css/components/duplicates.css | 259 +++++++++++++++ static/css/style.css | 1 + static/js/components/DuplicatesManager.js | 380 ++++++++++++++++++++++ static/js/components/RecipeCard.js | 61 ++-- static/js/recipes.js | 29 ++ static/js/state/index.js | 1 + static/js/utils/infiniteScroll.js | 5 + templates/recipes.html | 23 ++ 9 files changed, 736 insertions(+), 27 deletions(-) create mode 100644 static/css/components/duplicates.css create mode 100644 static/js/components/DuplicatesManager.js diff --git a/static/css/base.css b/static/css/base.css index ddc8352b..5dbf15f0 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -38,7 +38,7 @@ html, body { --lora-border: oklch(90% 0.02 256 / 0.15); --lora-text: oklch(95% 0.02 256); --lora-error: oklch(75% 0.32 29); - --lora-warning: oklch(75% 0.25 80); /* Add warning color for deleted LoRAs */ + --lora-warning: oklch(75% 0.25 80); /* Modified to be used with oklch() */ /* Spacing Scale */ --space-1: calc(8px * 1); @@ -79,7 +79,7 @@ html[data-theme="light"] { --lora-surface: oklch(25% 0.02 256 / 0.98); --lora-border: oklch(90% 0.02 256 / 0.15); --lora-text: oklch(98% 0.02 256); - --lora-warning: oklch(75% 0.25 80); /* Add warning color for dark theme too */ + --lora-warning: oklch(75% 0.25 80); /* Modified to be used with oklch() */ } body { diff --git a/static/css/components/duplicates.css b/static/css/components/duplicates.css new file mode 100644 index 00000000..302f0bc6 --- /dev/null +++ b/static/css/components/duplicates.css @@ -0,0 +1,259 @@ +/* Duplicates Management Styles */ + +/* Duplicates banner */ +.duplicates-banner { + position: sticky; + top: 48px; /* Match header height */ + left: 0; + width: 100%; + background-color: var(--card-bg); + color: var(--text-color); + border-bottom: 1px solid var(--border-color); + z-index: var(--z-overlay); + padding: 12px 16px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + transition: all 0.3s ease; +} + +.duplicates-banner .banner-content { + max-width: 1400px; + margin: 0 auto; + display: flex; + align-items: center; + gap: 12px; +} + +.duplicates-banner i.fa-exclamation-triangle { + font-size: 18px; + color: oklch(var(--lora-warning)); +} + +.duplicates-banner .banner-actions { + margin-left: auto; + display: flex; + gap: 8px; + align-items: center; +} + +.duplicates-banner button { + min-width: 100px; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + border-radius: var(--border-radius-xs); + padding: 4px 10px; + border: 1px solid var(--border-color); + background: var(--card-bg); + color: var(--text-color); + font-size: 0.85em; + transition: all 0.2s ease; + cursor: pointer; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.duplicates-banner button:hover { + border-color: var(--lora-accent); + background: var(--bg-color); + transform: translateY(-1px); + box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08); +} + +.duplicates-banner button.btn-exit { + min-width: unset; + width: 28px; + height: 28px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; +} + +.duplicates-banner button.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Duplicate groups */ +.duplicate-group { + position: relative; + border: 2px solid oklch(var(--lora-warning)); + border-radius: var(--border-radius-base); + padding: 16px; + margin-bottom: 24px; + background: var(--card-bg); +} + +.duplicate-group-header { + background-color: var(--bg-color); + color: var(--text-color); + border: 1px solid var(--border-color); + padding: 8px 16px; + border-radius: var(--border-radius-xs); + margin-bottom: 16px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.duplicate-group-header span:last-child { + display: flex; + gap: 8px; + align-items: center; +} + +.duplicate-group-header button { + min-width: 80px; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + border-radius: var(--border-radius-xs); + padding: 4px 8px; + border: 1px solid var(--border-color); + background: var(--card-bg); + color: var(--text-color); + font-size: 0.85em; + transition: all 0.2s ease; + cursor: pointer; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + margin-left: 8px; +} + +.duplicate-group-header button:hover { + border-color: var(--lora-accent); + background: var(--bg-color); + transform: translateY(-1px); + box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08); +} + +.card-group-container { + display: flex; + flex-wrap: wrap; + gap: 16px; + justify-content: flex-start; + align-items: flex-start; +} + +/* Make cards in duplicate groups have consistent width */ +.card-group-container .lora-card { + flex: 0 0 auto; + width: 240px; + margin: 0; + cursor: pointer; /* Indicate the card is clickable */ +} + +/* Ensure the grid layout is only applied to the main recipe grid, not duplicate groups */ +.duplicate-mode .card-grid { + display: block; +} + +/* Scrollable container for large duplicate groups */ +.card-group-container.scrollable { + max-height: 450px; + overflow-y: auto; + padding-right: 8px; +} + +/* Add a toggle button to expand/collapse large duplicate groups */ +.group-toggle-btn { + position: absolute; + right: 16px; + bottom: -12px; + background: var(--card-bg); + color: var(--text-color); + border: 1px solid var(--border-color); + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 1; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + transition: all 0.2s ease; +} + +.group-toggle-btn:hover { + border-color: var(--lora-accent); + transform: translateY(-1px); + box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08); +} + +/* Duplicate card styling */ +.lora-card.duplicate { + position: relative; + transition: all 0.2s ease; +} + +.lora-card.duplicate:hover { + border-color: var(--lora-accent); +} + +.lora-card.duplicate.latest { + border-style: solid; + border-color: oklch(var(--lora-warning)); +} + +.lora-card.duplicate-selected { + border: 2px solid oklch(var(--lora-accent)); + box-shadow: 0 0 8px rgba(0, 0, 0, 0.2); +} + +.lora-card .selector-checkbox { + position: absolute; + top: 10px; + right: 10px; + z-index: 10; + width: 20px; + height: 20px; + cursor: pointer; +} + +/* Latest indicator */ +.lora-card.duplicate.latest::after { + content: "Latest"; + position: absolute; + top: 10px; + left: 10px; + background: oklch(var(--lora-accent)); + color: white; + font-size: 12px; + padding: 2px 6px; + border-radius: var(--border-radius-xs); + z-index: 5; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .duplicates-banner .banner-content { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .duplicates-banner .banner-actions { + width: 100%; + margin-left: 0; + justify-content: space-between; + } + + .duplicate-group-header { + flex-direction: column; + gap: 8px; + align-items: flex-start; + } + + .duplicate-group-header span:last-child { + display: flex; + gap: 8px; + width: 100%; + } + + .duplicate-group-header button { + margin-left: 0; + flex: 1; + } +} diff --git a/static/css/style.css b/static/css/style.css index 08cb7a0d..64158a7f 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -22,6 +22,7 @@ @import 'components/initialization.css'; @import 'components/progress-panel.css'; @import 'components/alphabet-bar.css'; /* Add alphabet bar component */ +@import 'components/duplicates.css'; /* Add duplicates component */ .initialization-notice { display: flex; diff --git a/static/js/components/DuplicatesManager.js b/static/js/components/DuplicatesManager.js new file mode 100644 index 00000000..63713e4b --- /dev/null +++ b/static/js/components/DuplicatesManager.js @@ -0,0 +1,380 @@ +// Duplicates Manager Component +import { showToast } from '../utils/uiHelpers.js'; +import { RecipeCard } from './RecipeCard.js'; +import { getCurrentPageState } from '../state/index.js'; +import { initializeInfiniteScroll } from '../utils/infiniteScroll.js'; + +export class DuplicatesManager { + constructor(recipeManager) { + this.recipeManager = recipeManager; + this.duplicateGroups = []; + this.inDuplicateMode = false; + this.selectedForDeletion = new Set(); + } + + async findDuplicates() { + try { + document.body.classList.add('loading'); + + const response = await fetch('/api/recipes/find-duplicates'); + if (!response.ok) { + throw new Error('Failed to find duplicates'); + } + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || 'Unknown error finding duplicates'); + } + + this.duplicateGroups = data.duplicate_groups || []; + + if (this.duplicateGroups.length === 0) { + showToast('No duplicate recipes found', 'info'); + return false; + } + + this.enterDuplicateMode(); + return true; + } catch (error) { + console.error('Error finding duplicates:', error); + showToast('Failed to find duplicates: ' + error.message, 'error'); + return false; + } finally { + document.body.classList.remove('loading'); + } + } + + enterDuplicateMode() { + this.inDuplicateMode = true; + this.selectedForDeletion.clear(); + + // Update state + const pageState = getCurrentPageState(); + pageState.duplicatesMode = true; + + // Show duplicates banner + const banner = document.getElementById('duplicatesBanner'); + const countSpan = document.getElementById('duplicatesCount'); + + if (banner && countSpan) { + countSpan.textContent = `Found ${this.duplicateGroups.length} duplicate group${this.duplicateGroups.length !== 1 ? 's' : ''}`; + banner.style.display = 'block'; + } + + // Disable infinite scroll + if (this.recipeManager.observer) { + this.recipeManager.observer.disconnect(); + this.recipeManager.observer = null; + } + + // Add duplicate-mode class to the body + document.body.classList.add('duplicate-mode'); + + // Render duplicate groups + this.renderDuplicateGroups(); + + // Update selected count + this.updateSelectedCount(); + } + + exitDuplicateMode() { + this.inDuplicateMode = false; + this.selectedForDeletion.clear(); + + // Update state + const pageState = getCurrentPageState(); + pageState.duplicatesMode = false; + + // Hide duplicates banner + const banner = document.getElementById('duplicatesBanner'); + if (banner) { + banner.style.display = 'none'; + } + + // Remove duplicate-mode class from the body + document.body.classList.remove('duplicate-mode'); + + // Reload normal recipes view + this.recipeManager.loadRecipes(); + + // Reinitialize infinite scroll + setTimeout(() => { + initializeInfiniteScroll('recipes'); + }, 500); + } + + renderDuplicateGroups() { + const recipeGrid = document.getElementById('recipeGrid'); + if (!recipeGrid) return; + + // Clear existing content + recipeGrid.innerHTML = ''; + + // Render each duplicate group + this.duplicateGroups.forEach((group, groupIndex) => { + const groupDiv = document.createElement('div'); + groupDiv.className = 'duplicate-group'; + groupDiv.dataset.fingerprint = group.fingerprint; + + // Create group header + const header = document.createElement('div'); + header.className = 'duplicate-group-header'; + header.innerHTML = ` + Duplicate Group #${groupIndex + 1} (${group.recipes.length} recipes) + + + + + `; + groupDiv.appendChild(header); + + // Create cards container + const cardsDiv = document.createElement('div'); + cardsDiv.className = 'card-group-container'; + + // Add scrollable class if there are many recipes in the group + if (group.recipes.length > 6) { + cardsDiv.classList.add('scrollable'); + + // Add expand/collapse toggle button + const toggleBtn = document.createElement('button'); + toggleBtn.className = 'group-toggle-btn'; + toggleBtn.innerHTML = ''; + toggleBtn.title = "Expand/Collapse"; + toggleBtn.onclick = function() { + cardsDiv.classList.toggle('scrollable'); + this.innerHTML = cardsDiv.classList.contains('scrollable') ? + '' : + ''; + }; + groupDiv.appendChild(toggleBtn); + } + + // Sort recipes by date (newest first) + const sortedRecipes = [...group.recipes].sort((a, b) => b.modified - a.modified); + + // Add all recipe cards in this group + sortedRecipes.forEach((recipe, index) => { + // Create recipe card + const recipeCard = new RecipeCard(recipe, (recipe) => { + this.recipeManager.showRecipeDetails(recipe); + }); + const card = recipeCard.element; + + // Add duplicate class + card.classList.add('duplicate'); + + // Mark the latest one + if (index === 0) { + card.classList.add('latest'); + } + + // Add selection checkbox + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'selector-checkbox'; + checkbox.dataset.recipeId = recipe.id; + checkbox.dataset.groupFingerprint = group.fingerprint; + + // Check if already selected + if (this.selectedForDeletion.has(recipe.id)) { + checkbox.checked = true; + card.classList.add('duplicate-selected'); + } + + // Add change event to checkbox + checkbox.addEventListener('change', (e) => { + e.stopPropagation(); + this.toggleCardSelection(recipe.id, card, checkbox); + }); + + // Make the entire card clickable for selection + card.addEventListener('click', (e) => { + // Don't toggle if clicking on the checkbox directly or card actions + if (e.target === checkbox || e.target.closest('.card-actions')) { + return; + } + + // Toggle checkbox state + checkbox.checked = !checkbox.checked; + this.toggleCardSelection(recipe.id, card, checkbox); + }); + + card.appendChild(checkbox); + cardsDiv.appendChild(card); + }); + + groupDiv.appendChild(cardsDiv); + recipeGrid.appendChild(groupDiv); + }); + } + + // Helper method to toggle card selection state + toggleCardSelection(recipeId, card, checkbox) { + if (checkbox.checked) { + this.selectedForDeletion.add(recipeId); + card.classList.add('duplicate-selected'); + } else { + this.selectedForDeletion.delete(recipeId); + card.classList.remove('duplicate-selected'); + } + + this.updateSelectedCount(); + } + + updateSelectedCount() { + const selectedCountEl = document.getElementById('selectedCount'); + if (selectedCountEl) { + selectedCountEl.textContent = this.selectedForDeletion.size; + } + + // Update delete button state + const deleteBtn = document.querySelector('.btn-delete-selected'); + if (deleteBtn) { + deleteBtn.disabled = this.selectedForDeletion.size === 0; + deleteBtn.classList.toggle('disabled', this.selectedForDeletion.size === 0); + } + } + + toggleSelectAllInGroup(fingerprint) { + const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-fingerprint="${fingerprint}"]`); + const allSelected = Array.from(checkboxes).every(checkbox => checkbox.checked); + + // If all are selected, deselect all; otherwise select all + checkboxes.forEach(checkbox => { + checkbox.checked = !allSelected; + const recipeId = checkbox.dataset.recipeId; + const card = checkbox.closest('.lora-card'); + + if (!allSelected) { + this.selectedForDeletion.add(recipeId); + card.classList.add('duplicate-selected'); + } else { + this.selectedForDeletion.delete(recipeId); + card.classList.remove('duplicate-selected'); + } + }); + + // Update the button text + const button = document.querySelector(`.duplicate-group[data-fingerprint="${fingerprint}"] .btn-select-all`); + if (button) { + button.textContent = !allSelected ? "Deselect All" : "Select All"; + } + + this.updateSelectedCount(); + } + + selectAllInGroup(fingerprint) { + const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-fingerprint="${fingerprint}"]`); + checkboxes.forEach(checkbox => { + checkbox.checked = true; + this.selectedForDeletion.add(checkbox.dataset.recipeId); + checkbox.closest('.lora-card').classList.add('duplicate-selected'); + }); + + // Update the button text + const button = document.querySelector(`.duplicate-group[data-fingerprint="${fingerprint}"] .btn-select-all`); + if (button) { + button.textContent = "Deselect All"; + } + + this.updateSelectedCount(); + } + + selectLatestInGroup(fingerprint) { + // Find all checkboxes in this group + const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-fingerprint="${fingerprint}"]`); + + // Get all the recipes in this group + const group = this.duplicateGroups.find(g => g.fingerprint === fingerprint); + if (!group) return; + + // Sort recipes by date (newest first) + const sortedRecipes = [...group.recipes].sort((a, b) => b.modified - a.modified); + + // Skip the first (latest) one and select the rest for deletion + for (let i = 1; i < sortedRecipes.length; i++) { + const recipeId = sortedRecipes[i].id; + const checkbox = document.querySelector(`.selector-checkbox[data-recipe-id="${recipeId}"]`); + + if (checkbox) { + checkbox.checked = true; + this.selectedForDeletion.add(recipeId); + checkbox.closest('.lora-card').classList.add('duplicate-selected'); + } + } + + // Make sure the latest one is not selected + const latestId = sortedRecipes[0].id; + const latestCheckbox = document.querySelector(`.selector-checkbox[data-recipe-id="${latestId}"]`); + + if (latestCheckbox) { + latestCheckbox.checked = false; + this.selectedForDeletion.delete(latestId); + latestCheckbox.closest('.lora-card').classList.remove('duplicate-selected'); + } + + this.updateSelectedCount(); + } + + selectLatestDuplicates() { + // For each duplicate group, select all but the latest recipe + this.duplicateGroups.forEach(group => { + this.selectLatestInGroup(group.fingerprint); + }); + } + + async deleteSelectedDuplicates() { + if (this.selectedForDeletion.size === 0) { + showToast('No recipes selected for deletion', 'info'); + return; + } + + try { + // Show confirmation dialog + if (!confirm(`Are you sure you want to delete ${this.selectedForDeletion.size} selected recipes?`)) { + return; + } + + document.body.classList.add('loading'); + + // Prepare recipe IDs for deletion + const recipeIds = Array.from(this.selectedForDeletion); + + // Call API to bulk delete + const response = await fetch('/api/recipes/bulk-delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ recipe_ids: recipeIds }) + }); + + if (!response.ok) { + throw new Error('Failed to delete selected recipes'); + } + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || 'Unknown error deleting recipes'); + } + + showToast(`Successfully deleted ${data.total_deleted} recipes`, 'success'); + + // Exit duplicate mode if deletions were successful + if (data.total_deleted > 0) { + this.exitDuplicateMode(); + } + + } catch (error) { + console.error('Error deleting recipes:', error); + showToast('Failed to delete recipes: ' + error.message, 'error'); + } finally { + document.body.classList.remove('loading'); + } + } +} diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js index 29ea95c4..c120444b 100644 --- a/static/js/components/RecipeCard.js +++ b/static/js/components/RecipeCard.js @@ -1,6 +1,7 @@ // Recipe Card Component import { showToast, copyToClipboard } from '../utils/uiHelpers.js'; import { modalManager } from '../managers/ModalManager.js'; +import { getCurrentPageState } from '../state/index.js'; class RecipeCard { constructor(recipe, clickHandler) { @@ -36,10 +37,15 @@ class RecipeCard { (this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` : '/loras_static/images/no-preview.png'); + // Check if in duplicates mode + const pageState = getCurrentPageState(); + const isDuplicatesMode = pageState.duplicatesMode; + card.innerHTML = ` -
R
+ ${!isDuplicatesMode ? `
R
` : ''}
${this.recipe.title} + ${!isDuplicatesMode ? `
${baseModel ? `${baseModel}` : ''} @@ -50,19 +56,22 @@ class RecipeCard {
+ ` : ''}
`; - this.attachEventListeners(card); + this.attachEventListeners(card, isDuplicatesMode); return card; } @@ -72,29 +81,31 @@ class RecipeCard { return `${missingCount} of ${totalCount} LoRAs missing`; } - attachEventListeners(card) { - // Recipe card click event - card.addEventListener('click', () => { - this.clickHandler(this.recipe); - }); - - // Share button click event - prevent propagation to card - card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => { - e.stopPropagation(); - this.shareRecipe(); - }); - - // Copy button click event - prevent propagation to card - card.querySelector('.fa-copy')?.addEventListener('click', (e) => { - e.stopPropagation(); - this.copyRecipeSyntax(); - }); - - // Delete button click event - prevent propagation to card - card.querySelector('.fa-trash')?.addEventListener('click', (e) => { - e.stopPropagation(); - this.showDeleteConfirmation(); - }); + attachEventListeners(card, isDuplicatesMode) { + // Recipe card click event - only attach if not in duplicates mode + if (!isDuplicatesMode) { + card.addEventListener('click', () => { + this.clickHandler(this.recipe); + }); + + // Share button click event - prevent propagation to card + card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => { + e.stopPropagation(); + this.shareRecipe(); + }); + + // Copy button click event - prevent propagation to card + card.querySelector('.fa-copy')?.addEventListener('click', (e) => { + e.stopPropagation(); + this.copyRecipeSyntax(); + }); + + // Delete button click event - prevent propagation to card + card.querySelector('.fa-trash')?.addEventListener('click', (e) => { + e.stopPropagation(); + this.showDeleteConfirmation(); + }); + } } copyRecipeSyntax() { diff --git a/static/js/recipes.js b/static/js/recipes.js index 012478b9..0bca2aca 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -6,6 +6,8 @@ import { RecipeModal } from './components/RecipeModal.js'; import { getCurrentPageState } from './state/index.js'; import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js'; import { RecipeContextMenu } from './components/ContextMenu/index.js'; +import { DuplicatesManager } from './components/DuplicatesManager.js'; +import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; class RecipeManager { constructor() { @@ -18,6 +20,9 @@ class RecipeManager { // Initialize RecipeModal this.recipeModal = new RecipeModal(); + // Initialize DuplicatesManager + this.duplicatesManager = new DuplicatesManager(this); + // Add state tracking for infinite scroll this.pageState.isLoading = false; this.pageState.hasMore = true; @@ -179,6 +184,12 @@ class RecipeManager { async loadRecipes(resetPage = true) { try { + // Skip loading if in duplicates mode + const pageState = getCurrentPageState(); + if (pageState.duplicatesMode) { + return; + } + // Show loading indicator document.body.classList.add('loading'); this.pageState.isLoading = true; @@ -366,6 +377,24 @@ class RecipeManager { showRecipeDetails(recipe) { this.recipeModal.showRecipeDetails(recipe); } + + // Duplicate detection and management methods + async findDuplicateRecipes() { + return await this.duplicatesManager.findDuplicates(); + } + + selectLatestDuplicates() { + this.duplicatesManager.selectLatestDuplicates(); + } + + deleteSelectedDuplicates() { + this.duplicatesManager.deleteSelectedDuplicates(); + } + + exitDuplicateMode() { + this.duplicatesManager.exitDuplicateMode(); + initializeInfiniteScroll(); + } } // Initialize components diff --git a/static/js/state/index.js b/static/js/state/index.js index bbfa3ea9..6851baff 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -65,6 +65,7 @@ export const state = { }, pageSize: 20, showFavoritesOnly: false, + duplicatesMode: false, // Add flag for duplicates mode }, checkpoints: { diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js index 57290ebd..40ca85af 100644 --- a/static/js/utils/infiniteScroll.js +++ b/static/js/utils/infiniteScroll.js @@ -14,6 +14,11 @@ export function initializeInfiniteScroll(pageType = 'loras') { // Get the current page state const pageState = getCurrentPageState(); + + // Skip initializing if in duplicates mode (for recipes page) + if (pageType === 'recipes' && pageState.duplicatesMode) { + return; + } // Determine the load more function and grid ID based on page type let loadMoreFunction; diff --git a/templates/recipes.html b/templates/recipes.html index 1c97d78b..7701d3e1 100644 --- a/templates/recipes.html +++ b/templates/recipes.html @@ -42,6 +42,10 @@
+ +
+ +
+ + +