feat: implement duplicate detection and management features; add UI components and styles for duplicates

This commit is contained in:
Will Miao
2025-05-08 13:05:12 +08:00
parent e92ab9e3cc
commit 59aefdff77
9 changed files with 736 additions and 27 deletions

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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 = `
<span>Duplicate Group #${groupIndex + 1} (${group.recipes.length} recipes)</span>
<span>
<button class="btn-select-all" onclick="recipeManager.duplicatesManager.toggleSelectAllInGroup('${group.fingerprint}')">
Select All
</button>
<button class="btn-select-latest" onclick="recipeManager.duplicatesManager.selectLatestInGroup('${group.fingerprint}')">
Keep Latest
</button>
</span>
`;
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 = '<i class="fas fa-chevron-down"></i>';
toggleBtn.title = "Expand/Collapse";
toggleBtn.onclick = function() {
cardsDiv.classList.toggle('scrollable');
this.innerHTML = cardsDiv.classList.contains('scrollable') ?
'<i class="fas fa-chevron-down"></i>' :
'<i class="fas fa-chevron-up"></i>';
};
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');
}
}
}

View File

@@ -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 = `
<div class="recipe-indicator" title="Recipe">R</div>
${!isDuplicatesMode ? `<div class="recipe-indicator" title="Recipe">R</div>` : ''}
<div class="card-preview">
<img src="${imageUrl}" alt="${this.recipe.title}">
${!isDuplicatesMode ? `
<div class="card-header">
<div class="base-model-wrapper">
${baseModel ? `<span class="base-model-label" title="${baseModel}">${baseModel}</span>` : ''}
@@ -50,19 +56,22 @@ class RecipeCard {
<i class="fas fa-trash" title="Delete Recipe"></i>
</div>
</div>
` : ''}
<div class="card-footer">
<div class="model-info">
<span class="model-name">${this.recipe.title}</span>
</div>
${!isDuplicatesMode ? `
<div class="lora-count ${allLorasAvailable ? 'ready' : (lorasCount > 0 ? 'missing' : '')}"
title="${this.getLoraStatusTitle(lorasCount, missingLorasCount)}">
<i class="fas fa-layer-group"></i> ${lorasCount}
</div>
` : ''}
</div>
</div>
`;
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() {

View File

@@ -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

View File

@@ -65,6 +65,7 @@ export const state = {
},
pageSize: 20,
showFavoritesOnly: false,
duplicatesMode: false, // Add flag for duplicates mode
},
checkpoints: {

View File

@@ -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;

View File

@@ -42,6 +42,10 @@
<div title="Import recipes" class="control-group">
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> Import</button>
</div>
<!-- Add duplicate detection button -->
<div title="Find duplicate recipes" class="control-group">
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> Find Duplicates</button>
</div>
<!-- Custom filter indicator button (hidden by default) -->
<div id="customFilterIndicator" class="control-group hidden">
<div class="filter-active">
@@ -51,6 +55,25 @@
</div>
</div>
</div>
<!-- Duplicates banner (hidden by default) -->
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
<div class="banner-content">
<i class="fas fa-exclamation-triangle"></i>
<span id="duplicatesCount">Found 0 duplicate groups</span>
<div class="banner-actions">
<button class="btn-select-latest" onclick="recipeManager.selectLatestDuplicates()">
Select Latest Versions
</button>
<button class="btn-delete-selected disabled" onclick="recipeManager.deleteSelectedDuplicates()">
Delete Selected (<span id="selectedCount">0</span>)
</button>
<button class="btn-exit" onclick="recipeManager.exitDuplicateMode()">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<!-- Recipe grid -->
<div class="card-grid" id="recipeGrid">