mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 07:05:43 -03:00
feat: implement duplicate detection and management features; add UI components and styles for duplicates
This commit is contained in:
@@ -38,7 +38,7 @@ html, body {
|
|||||||
--lora-border: oklch(90% 0.02 256 / 0.15);
|
--lora-border: oklch(90% 0.02 256 / 0.15);
|
||||||
--lora-text: oklch(95% 0.02 256);
|
--lora-text: oklch(95% 0.02 256);
|
||||||
--lora-error: oklch(75% 0.32 29);
|
--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 */
|
/* Spacing Scale */
|
||||||
--space-1: calc(8px * 1);
|
--space-1: calc(8px * 1);
|
||||||
@@ -79,7 +79,7 @@ html[data-theme="light"] {
|
|||||||
--lora-surface: oklch(25% 0.02 256 / 0.98);
|
--lora-surface: oklch(25% 0.02 256 / 0.98);
|
||||||
--lora-border: oklch(90% 0.02 256 / 0.15);
|
--lora-border: oklch(90% 0.02 256 / 0.15);
|
||||||
--lora-text: oklch(98% 0.02 256);
|
--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 {
|
body {
|
||||||
|
|||||||
259
static/css/components/duplicates.css
Normal file
259
static/css/components/duplicates.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
@import 'components/initialization.css';
|
@import 'components/initialization.css';
|
||||||
@import 'components/progress-panel.css';
|
@import 'components/progress-panel.css';
|
||||||
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */
|
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */
|
||||||
|
@import 'components/duplicates.css'; /* Add duplicates component */
|
||||||
|
|
||||||
.initialization-notice {
|
.initialization-notice {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
380
static/js/components/DuplicatesManager.js
Normal file
380
static/js/components/DuplicatesManager.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// Recipe Card Component
|
// Recipe Card Component
|
||||||
import { showToast, copyToClipboard } from '../utils/uiHelpers.js';
|
import { showToast, copyToClipboard } from '../utils/uiHelpers.js';
|
||||||
import { modalManager } from '../managers/ModalManager.js';
|
import { modalManager } from '../managers/ModalManager.js';
|
||||||
|
import { getCurrentPageState } from '../state/index.js';
|
||||||
|
|
||||||
class RecipeCard {
|
class RecipeCard {
|
||||||
constructor(recipe, clickHandler) {
|
constructor(recipe, clickHandler) {
|
||||||
@@ -36,10 +37,15 @@ class RecipeCard {
|
|||||||
(this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` :
|
(this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` :
|
||||||
'/loras_static/images/no-preview.png');
|
'/loras_static/images/no-preview.png');
|
||||||
|
|
||||||
|
// Check if in duplicates mode
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
|
const isDuplicatesMode = pageState.duplicatesMode;
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="recipe-indicator" title="Recipe">R</div>
|
${!isDuplicatesMode ? `<div class="recipe-indicator" title="Recipe">R</div>` : ''}
|
||||||
<div class="card-preview">
|
<div class="card-preview">
|
||||||
<img src="${imageUrl}" alt="${this.recipe.title}">
|
<img src="${imageUrl}" alt="${this.recipe.title}">
|
||||||
|
${!isDuplicatesMode ? `
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="base-model-wrapper">
|
<div class="base-model-wrapper">
|
||||||
${baseModel ? `<span class="base-model-label" title="${baseModel}">${baseModel}</span>` : ''}
|
${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>
|
<i class="fas fa-trash" title="Delete Recipe"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
` : ''}
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<div class="model-info">
|
<div class="model-info">
|
||||||
<span class="model-name">${this.recipe.title}</span>
|
<span class="model-name">${this.recipe.title}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${!isDuplicatesMode ? `
|
||||||
<div class="lora-count ${allLorasAvailable ? 'ready' : (lorasCount > 0 ? 'missing' : '')}"
|
<div class="lora-count ${allLorasAvailable ? 'ready' : (lorasCount > 0 ? 'missing' : '')}"
|
||||||
title="${this.getLoraStatusTitle(lorasCount, missingLorasCount)}">
|
title="${this.getLoraStatusTitle(lorasCount, missingLorasCount)}">
|
||||||
<i class="fas fa-layer-group"></i> ${lorasCount}
|
<i class="fas fa-layer-group"></i> ${lorasCount}
|
||||||
</div>
|
</div>
|
||||||
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.attachEventListeners(card);
|
this.attachEventListeners(card, isDuplicatesMode);
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,8 +81,9 @@ class RecipeCard {
|
|||||||
return `${missingCount} of ${totalCount} LoRAs missing`;
|
return `${missingCount} of ${totalCount} LoRAs missing`;
|
||||||
}
|
}
|
||||||
|
|
||||||
attachEventListeners(card) {
|
attachEventListeners(card, isDuplicatesMode) {
|
||||||
// Recipe card click event
|
// Recipe card click event - only attach if not in duplicates mode
|
||||||
|
if (!isDuplicatesMode) {
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
this.clickHandler(this.recipe);
|
this.clickHandler(this.recipe);
|
||||||
});
|
});
|
||||||
@@ -96,6 +106,7 @@ class RecipeCard {
|
|||||||
this.showDeleteConfirmation();
|
this.showDeleteConfirmation();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
copyRecipeSyntax() {
|
copyRecipeSyntax() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { RecipeModal } from './components/RecipeModal.js';
|
|||||||
import { getCurrentPageState } from './state/index.js';
|
import { getCurrentPageState } from './state/index.js';
|
||||||
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||||
|
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||||
|
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
||||||
|
|
||||||
class RecipeManager {
|
class RecipeManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -18,6 +20,9 @@ class RecipeManager {
|
|||||||
// Initialize RecipeModal
|
// Initialize RecipeModal
|
||||||
this.recipeModal = new RecipeModal();
|
this.recipeModal = new RecipeModal();
|
||||||
|
|
||||||
|
// Initialize DuplicatesManager
|
||||||
|
this.duplicatesManager = new DuplicatesManager(this);
|
||||||
|
|
||||||
// Add state tracking for infinite scroll
|
// Add state tracking for infinite scroll
|
||||||
this.pageState.isLoading = false;
|
this.pageState.isLoading = false;
|
||||||
this.pageState.hasMore = true;
|
this.pageState.hasMore = true;
|
||||||
@@ -179,6 +184,12 @@ class RecipeManager {
|
|||||||
|
|
||||||
async loadRecipes(resetPage = true) {
|
async loadRecipes(resetPage = true) {
|
||||||
try {
|
try {
|
||||||
|
// Skip loading if in duplicates mode
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
|
if (pageState.duplicatesMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Show loading indicator
|
// Show loading indicator
|
||||||
document.body.classList.add('loading');
|
document.body.classList.add('loading');
|
||||||
this.pageState.isLoading = true;
|
this.pageState.isLoading = true;
|
||||||
@@ -366,6 +377,24 @@ class RecipeManager {
|
|||||||
showRecipeDetails(recipe) {
|
showRecipeDetails(recipe) {
|
||||||
this.recipeModal.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
|
// Initialize components
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const state = {
|
|||||||
},
|
},
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
showFavoritesOnly: false,
|
showFavoritesOnly: false,
|
||||||
|
duplicatesMode: false, // Add flag for duplicates mode
|
||||||
},
|
},
|
||||||
|
|
||||||
checkpoints: {
|
checkpoints: {
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export function initializeInfiniteScroll(pageType = 'loras') {
|
|||||||
// Get the current page state
|
// Get the current page state
|
||||||
const pageState = getCurrentPageState();
|
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
|
// Determine the load more function and grid ID based on page type
|
||||||
let loadMoreFunction;
|
let loadMoreFunction;
|
||||||
let gridId;
|
let gridId;
|
||||||
|
|||||||
@@ -42,6 +42,10 @@
|
|||||||
<div title="Import recipes" class="control-group">
|
<div title="Import recipes" class="control-group">
|
||||||
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> Import</button>
|
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> Import</button>
|
||||||
</div>
|
</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) -->
|
<!-- Custom filter indicator button (hidden by default) -->
|
||||||
<div id="customFilterIndicator" class="control-group hidden">
|
<div id="customFilterIndicator" class="control-group hidden">
|
||||||
<div class="filter-active">
|
<div class="filter-active">
|
||||||
@@ -52,6 +56,25 @@
|
|||||||
</div>
|
</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 -->
|
<!-- Recipe grid -->
|
||||||
<div class="card-grid" id="recipeGrid">
|
<div class="card-grid" id="recipeGrid">
|
||||||
{% if recipes and recipes|length > 0 %}
|
{% if recipes and recipes|length > 0 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user