feat(bulk): consensus ML suggestions + frontend polish

Bulk editor now loads consensus ML suggestions across the current
selection via a new POST /api/suggestions/bulk endpoint, powered by
get_bulk_suggestions() in tag_suggestions.py. A tag is surfaced only if
it was suggested for or already applied to >= 80% of the selection;
coverage counts include images that already have the tag so a near-
universal tag isn't penalized for dropping out of suggestion lists.
Accept-only chips (no reject) match the rest of the suggestions UX.

Frontend polish in the same commit:
- Showcase session dedup (exclude seen ids, reset on second lap) and
  aspect-ratio-aware placeholder heights for better column balancing
- Modal touch-swipe navigation on the image wrapper, auto-focus of the
  tag input on desktop (>=768px), and autocomplete flip-up when the
  dropdown would cover the Suggestions section
- Settings template inline styles replaced with utility classes
- Inline series-editor script extracted to gallery-series-editor.js

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 18:39:47 -04:00
parent bc6b0a4a58
commit 3944605d33
9 changed files with 693 additions and 158 deletions
+237 -63
View File
@@ -19,14 +19,14 @@
<div class="settings-grid-full">
<div class="settings-container">
<div class="settings-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
<h2 style="margin: 0;">System Overview</h2>
<div style="display: flex; align-items: center; gap: 0.75rem;">
<span id="statsUpdatedAt" style="font-size: 0.75rem; color: var(--text-muted);"></span>
<button id="refreshStatsBtn" class="btn secondary-btn" style="padding: 0.3rem 0.6rem; font-size: 0.8rem;" title="Refresh stats">Refresh</button>
<div class="settings-section-header">
<h2>System Overview</h2>
<div class="settings-header-actions">
<span id="statsUpdatedAt" class="text-muted-xs"></span>
<button id="refreshStatsBtn" class="btn secondary-btn btn-compact" title="Refresh stats">Refresh</button>
</div>
</div>
<div class="stats-grid" style="grid-template-columns: repeat(5, 1fr);">
<div class="stats-grid stats-grid-5">
<div class="stat-item">
<span class="stat-value" id="statTotalImages">-</span>
<span class="stat-label">Total Images</span>
@@ -60,7 +60,7 @@
<p class="settings-description">Monitor and control the Celery-based import queue system.</p>
<div id="queueStatusSection">
<div class="stats-grid" style="grid-template-columns: repeat(6, 1fr);">
<div class="stats-grid stats-grid-6">
<div class="stat-item">
<span class="stat-value" id="queuePending">-</span>
<span class="stat-label">Pending</span>
@@ -88,14 +88,14 @@
</div>
<!-- Age breakdown tooltip -->
<div id="ageBreakdown" style="display: none; margin-top: 0.5rem; padding: 0.5rem; background: rgba(255,255,255,0.05); border-radius: 4px; font-size: 0.8rem;">
<span id="ageBreakdownTitle" style="color: var(--text-muted);"></span>:
<div id="ageBreakdown" class="age-breakdown" style="display: none;">
<span id="ageBreakdownTitle" class="text-muted"></span>:
<span id="ageBreakdownToday">-</span> today,
<span id="ageBreakdownWeek">-</span> this week,
<span id="ageBreakdownOlder">-</span> older
</div>
<div id="activeBatchInfo" class="import-stats" style="margin-top: 1rem; display: none;">
<div id="activeBatchInfo" class="import-stats mt-md" style="display: none;">
<h3>Active Batch</h3>
<p>Processing <strong id="batchDir">-</strong></p>
<div class="batch-progress">
@@ -106,7 +106,7 @@
</div>
</div>
<div class="queue-actions" style="margin-top: 1rem; display: flex; gap: 0.75rem; flex-wrap: wrap;">
<div class="queue-actions">
<button id="triggerImportBtn" class="btn primary-btn">Quick Scan</button>
<button id="triggerDeepScanBtn" class="btn warning-btn" title="Full reprocessing: re-extracts metadata, runs pHash comparison, merges archive tags">Deep Scan</button>
<button id="retryFailedBtn" class="btn warning-btn">Retry Failed Tasks</button>
@@ -114,17 +114,17 @@
</div>
<!-- Task Cleanup Controls -->
<div class="cleanup-controls" style="margin-top: 1rem; padding: 0.75rem; background: rgba(255,255,255,0.03); border-radius: 6px;">
<div style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;">
<span style="color: var(--text-muted); font-size: 0.85rem;">Clear tasks:</span>
<select id="clearStatusSelect" class="form-input" style="width: auto; min-width: 140px;">
<div class="cleanup-controls">
<div class="cleanup-controls-row">
<span class="text-muted-sm">Clear tasks:</span>
<select id="clearStatusSelect" class="form-input select-compact">
<option value="complete,skipped">Complete + Skipped</option>
<option value="complete">Complete only</option>
<option value="skipped">Skipped only</option>
<option value="failed">Failed only</option>
<option value="complete,skipped,failed">All finished</option>
</select>
<select id="clearAgeSelect" class="form-input" style="width: auto; min-width: 120px;">
<select id="clearAgeSelect" class="form-input select-compact-sm">
<option value="0">All</option>
<option value="1">Older than 1 day</option>
<option value="7" selected>Older than 7 days</option>
@@ -132,17 +132,17 @@
</select>
<button id="clearTasksBtn" class="btn secondary-btn">Clear</button>
</div>
<p style="color: var(--text-muted); font-size: 0.75rem; margin-top: 0.5rem;">
<p class="cleanup-controls-note">
Note: Failed/skipped tasks older than 7 days are auto-cleaned daily.
</p>
</div>
<!-- Task List with Filters -->
<div id="taskListSection" style="margin-top: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem;">
<h3 style="margin: 0;">Import Tasks</h3>
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<select id="taskStatusFilter" class="form-input" style="width: auto; min-width: 120px; padding: 0.4rem;">
<div id="taskListSection" class="mt-lg">
<div class="task-list-toolbar">
<h3>Import Tasks</h3>
<div class="task-list-filters">
<select id="taskStatusFilter" class="form-input select-compact-sm">
<option value="">All statuses</option>
<option value="failed">Failed</option>
<option value="skipped">Skipped</option>
@@ -151,57 +151,57 @@
<option value="queued">Queued</option>
<option value="pending">Pending</option>
</select>
<input type="text" id="taskSearchInput" class="form-input" placeholder="Search..." style="width: 150px; padding: 0.4rem;">
<button id="taskSearchBtn" class="btn secondary-btn" style="padding: 0.4rem 0.75rem;">Search</button>
<input type="text" id="taskSearchInput" class="form-input task-list-search" placeholder="Search...">
<button id="taskSearchBtn" class="btn secondary-btn btn-compact-md">Search</button>
</div>
</div>
<div id="taskListInfo" style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.5rem;">
<div id="taskListInfo" class="task-list-info">
Showing <span id="taskListShowing">0</span> of <span id="taskListTotal">0</span> tasks
</div>
<div class="tasks-table-wrapper" style="max-height: 400px; overflow-y: auto;">
<div class="tasks-table-wrapper">
<table class="tasks-table">
<thead>
<tr>
<th style="width: 40%;">File</th>
<th style="width: 10%;">Type</th>
<th style="width: 10%;">Status</th>
<th style="width: 30%;">Error/Reason</th>
<th style="width: 10%;">Time</th>
<th class="col-file">File</th>
<th class="col-type">Type</th>
<th class="col-status">Status</th>
<th class="col-error">Error/Reason</th>
<th class="col-time">Time</th>
</tr>
</thead>
<tbody id="taskListBody">
<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">Loading...</td></tr>
<tr><td colspan="5" class="table-message-cell">Loading...</td></tr>
</tbody>
</table>
</div>
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
<div class="table-actions">
<button id="loadMoreTasksBtn" class="btn secondary-btn" style="display: none;">Load More</button>
</div>
</div>
<!-- Error Detail Modal -->
<div id="errorDetailModal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 600px;">
<div class="modal-content error-detail-content">
<div class="modal-header">
<h3>Task Details</h3>
<button class="modal-close" onclick="document.getElementById('errorDetailModal').style.display='none'">&times;</button>
</div>
<div class="modal-body">
<div style="margin-bottom: 1rem;">
<div class="error-detail-row">
<strong>File:</strong>
<div id="errorDetailPath" style="word-break: break-all; font-family: monospace; font-size: 0.85rem; background: rgba(0,0,0,0.2); padding: 0.5rem; border-radius: 4px; margin-top: 0.25rem;"></div>
<div id="errorDetailPath" class="error-detail-path"></div>
</div>
<div style="margin-bottom: 1rem;">
<div class="error-detail-row">
<strong>Status:</strong> <span id="errorDetailStatus"></span>
</div>
<div style="margin-bottom: 1rem;">
<div class="error-detail-row">
<strong>Error/Reason:</strong>
<div id="errorDetailMessage" style="word-break: break-word; font-family: monospace; font-size: 0.85rem; background: rgba(255,100,100,0.1); padding: 0.5rem; border-radius: 4px; margin-top: 0.25rem; white-space: pre-wrap;"></div>
<div id="errorDetailMessage" class="error-detail-message"></div>
</div>
<div style="display: flex; gap: 1rem; font-size: 0.85rem; color: var(--text-muted);">
<div class="error-detail-meta">
<span><strong>Retries:</strong> <span id="errorDetailRetries"></span></span>
<span><strong>Created:</strong> <span id="errorDetailCreated"></span></span>
</div>
@@ -265,7 +265,7 @@
<p class="form-help">Controls how similar images must be to be considered duplicates.</p>
</div>
<button type="submit" class="btn primary-btn" style="width: 100%;">Save Filter Settings</button>
<button type="submit" class="btn primary-btn btn-fullwidth">Save Filter Settings</button>
</form>
</div>
</div>
@@ -305,12 +305,12 @@
</label>
</div>
<button type="submit" class="btn danger-btn" style="width: 100%;" disabled id="deleteByTagBtn">Delete Images</button>
<button type="submit" class="btn danger-btn btn-fullwidth" disabled id="deleteByTagBtn">Delete Images</button>
</form>
</div>
</div>
<div class="settings-container" style="margin-top: 1rem;">
<div class="settings-container mt-md">
<div class="settings-section">
<h2>Clean Up Existing Images</h2>
<p class="settings-description">Scan and remove images that don't meet filter criteria.</p>
@@ -343,10 +343,10 @@
</div>
</div>
<button type="button" id="scanFilterBtn" class="btn primary-btn" style="width: 100%;">Scan for Matching Images</button>
<button type="button" id="scanFilterBtn" class="btn primary-btn btn-fullwidth">Scan for Matching Images</button>
</div>
<div id="cleanupResults" style="display: none; margin-top: 1rem;">
<div id="cleanupResults" class="mt-md" style="display: none;">
<div class="import-stats">
<h3>Scan Results</h3>
<p><strong id="cleanupCount">0</strong> images match the filter criteria.</p>
@@ -354,27 +354,27 @@
<div id="cleanupPreview" class="cleanup-preview"></div>
<div id="cleanupActions" style="display: none; margin-top: 1rem;">
<div id="cleanupActions" class="mt-md" style="display: none;">
<div class="form-group">
<label class="form-label checkbox-label">
<input type="checkbox" id="cleanupSelectAll">
Select all for deletion
</label>
</div>
<p class="form-help" style="margin-bottom: 0.75rem;">Selected: <strong id="cleanupSelectedCount">0</strong> images</p>
<button type="button" id="deleteSelectedBtn" class="btn danger-btn" style="width: 100%;" disabled>Delete Selected Images</button>
<p class="form-help mb-sm">Selected: <strong id="cleanupSelectedCount">0</strong> images</p>
<button type="button" id="deleteSelectedBtn" class="btn danger-btn btn-fullwidth" disabled>Delete Selected Images</button>
</div>
</div>
</div>
</div>
<!-- Duplicate Detection -->
<div class="settings-container" style="margin-top: 1rem;">
<div class="settings-container mt-md">
<div class="settings-section">
<h2>Duplicate Detection</h2>
<p class="settings-description">Scan for visually similar images using perceptual hash comparison. The first image in each group has the highest resolution and will be kept.</p>
<div class="form-card" style="margin-bottom: 1rem;">
<div class="form-card mb-md">
<div class="form-group">
<label class="form-label">Hamming Distance Threshold</label>
<select id="dupThreshold" class="form-input">
@@ -386,21 +386,21 @@
<p class="form-help">Lower values find only very similar images. Higher values find more potential duplicates but may include false positives.</p>
</div>
<button type="button" id="scanDuplicatesBtn" class="btn primary-btn" style="width: 100%;">Scan for Duplicates</button>
<button type="button" id="scanDuplicatesBtn" class="btn primary-btn btn-fullwidth">Scan for Duplicates</button>
</div>
<div id="dupResults" style="display: none;">
<div class="import-stats" style="margin-bottom: 1rem;">
<div class="import-stats mb-md">
<h3>Scan Results</h3>
<p>Found <strong id="dupGroupCount">0</strong> group(s) of potential duplicates.</p>
</div>
<div id="dupGroups"></div>
<div id="dupActions" style="margin-top: 1rem; display: none;">
<p class="form-help" style="margin-bottom: 0.75rem;">
<div id="dupActions" class="mt-md" style="display: none;">
<p class="form-help mb-sm">
Selected for deletion: <strong id="dupSelectedCount">0</strong> image(s)
<span style="color: var(--text-muted);">(The highest resolution image in each group is protected)</span>
<span class="text-muted">(The highest resolution image in each group is protected)</span>
</p>
<button type="button" id="deleteDuplicatesBtn" class="btn danger-btn" disabled>Delete Selected Duplicates</button>
</div>
@@ -423,14 +423,14 @@
<h2>Maintenance Tools</h2>
<p class="settings-description">Background operations and worker management.</p>
<div class="queue-actions" style="margin-top: 0.75rem; display: flex; gap: 0.75rem; flex-wrap: wrap;">
<div class="queue-actions-compact">
<button id="regenMissingThumbsBtn" class="btn secondary-btn">Regenerate Missing Thumbnails</button>
<button id="reapplyArtistTagsBtn" class="btn secondary-btn">Reapply Artist Tags from Paths</button>
<button id="cleanupOrphanedTagsBtn" class="btn secondary-btn">Cleanup Orphaned Tags</button>
</div>
<div id="celeryWorkerInfo" style="margin-top: 1rem;">
<p style="color: var(--text-muted); font-size: 0.85rem;">
<div id="celeryWorkerInfo" class="mt-md">
<p class="text-muted-sm">
Workers: <strong id="workerCount">-</strong> |
Active Tasks: <strong id="activeTasks">-</strong>
</p>
@@ -447,7 +447,7 @@
<button type="submit" class="btn secondary-btn">Run ML backfill</button>
</form>
<p class="settings-hint" style="margin-top: 1em;">
<p class="settings-hint mt-paragraph">
Recompute centroids for all eligible tags (character, fandom, and general/topic) that have at least
the minimum number of reference images. Run this after the initial backfill, or any time you've
applied many tags manually and want the suggestions to catch up.
@@ -456,7 +456,7 @@
<button type="submit" class="btn secondary-btn">Recompute all centroids</button>
</form>
<p class="settings-hint" style="margin-top: 1em;">
<p class="settings-hint mt-paragraph">
Attach each character's fandom to every image already tagged with that character.
Additive only — existing fandom tags are never removed. Run after you've set a
fandom on an existing character, or whenever you suspect drift.
@@ -810,6 +810,180 @@
.tasks-table .error-cell:hover {
text-decoration: underline;
}
/* === Utility classes === */
.mt-md { margin-top: 1rem; }
.mt-sm { margin-top: 0.75rem; }
.mt-lg { margin-top: 1.5rem; }
.mt-paragraph { margin-top: 1em; }
.mb-md { margin-bottom: 1rem; }
.mb-sm { margin-bottom: 0.75rem; }
.text-muted-xs { font-size: 0.75rem; color: var(--text-muted); }
.text-muted-sm { font-size: 0.85rem; color: var(--text-muted); }
.btn-compact {
padding: 0.3rem 0.6rem;
font-size: 0.8rem;
min-width: auto;
}
.btn-compact-md {
padding: 0.4rem 0.75rem;
min-width: auto;
}
.btn-fullwidth { width: 100%; min-width: 0; }
.select-compact {
width: auto;
min-width: 140px;
}
.select-compact-sm {
width: auto;
min-width: 120px;
padding: 0.4rem;
}
/* === Section headers === */
.settings-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.settings-section-header h2,
.settings-section-header h3 {
margin: 0;
text-align: left;
}
.settings-header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* === Stats grid variants === */
.stats-grid-5 { grid-template-columns: repeat(5, 1fr); }
.stats-grid-6 { grid-template-columns: repeat(6, 1fr); }
/* === Age breakdown info panel === */
.age-breakdown {
margin-top: 0.5rem;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
font-size: 0.8rem;
}
/* === Import queue layout === */
.queue-actions {
margin-top: 1rem;
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.queue-actions-compact {
margin-top: 0.75rem;
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
/* === Cleanup controls === */
.cleanup-controls {
margin-top: 1rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
}
.cleanup-controls-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.cleanup-controls-note {
color: var(--text-muted);
font-size: 0.75rem;
margin-top: 0.5rem;
}
/* === Task list === */
.task-list-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.task-list-toolbar h3 { margin: 0; }
.task-list-filters {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.task-list-search {
width: 150px;
padding: 0.4rem;
}
.task-list-info {
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.tasks-table-wrapper {
max-height: 400px;
overflow-y: auto;
}
.tasks-table .col-file { width: 40%; }
.tasks-table .col-type { width: 10%; }
.tasks-table .col-status { width: 10%; }
.tasks-table .col-error { width: 30%; }
.tasks-table .col-time { width: 10%; }
.table-message-cell {
text-align: center;
color: var(--text-muted);
}
.table-actions {
margin-top: 0.75rem;
display: flex;
gap: 0.5rem;
}
/* === Error detail modal === */
.error-detail-content { max-width: 600px; }
.error-detail-row { margin-bottom: 1rem; }
.error-detail-path,
.error-detail-message {
word-break: break-all;
font-family: monospace;
font-size: 0.85rem;
padding: 0.5rem;
border-radius: 4px;
margin-top: 0.25rem;
}
.error-detail-path {
background: rgba(0, 0, 0, 0.2);
}
.error-detail-message {
word-break: break-word;
background: rgba(255, 100, 100, 0.1);
white-space: pre-wrap;
}
.error-detail-meta {
display: flex;
gap: 1rem;
font-size: 0.85rem;
color: var(--text-muted);
}
/* === Empty state (used by JS-injected content) === */
.empty-state-centered {
text-align: center;
color: var(--text-muted);
padding: 1rem;
}
</style>
<script>
@@ -894,7 +1068,7 @@ document.addEventListener('DOMContentLoaded', () => {
</tr>
`).join('');
} else {
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">No tasks found</td></tr>';
tbody.innerHTML = '<tr><td colspan="5" class="table-message-cell">No tasks found</td></tr>';
}
// Update info
@@ -1578,7 +1752,7 @@ document.addEventListener('DOMContentLoaded', () => {
resultsEl.style.display = 'block';
if (cleanupImages.length === 0) {
previewEl.innerHTML = '<p style="text-align: center; color: var(--text-muted); padding: 1rem;">No images match the filter criteria.</p>';
previewEl.innerHTML = '<p class="empty-state-centered">No images match the filter criteria.</p>';
actionsEl.style.display = 'none';
return;
}
@@ -1737,7 +1911,7 @@ document.addEventListener('DOMContentLoaded', () => {
resultsEl.style.display = 'block';
if (duplicateGroups.length === 0) {
groupsEl.innerHTML = '<p style="text-align: center; color: var(--text-muted); padding: 1rem;">No duplicate images found.</p>';
groupsEl.innerHTML = '<p class="empty-state-centered">No duplicate images found.</p>';
actionsEl.style.display = 'none';
return;
}