This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
imagerepo/app/templates/settings.html
T
bvandeusen 3944605d33 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>
2026-04-20 18:39:47 -04:00

2060 lines
73 KiB
HTML

{% extends "layout.html" %}
{% block title %}Settings{% endblock %}
{% block content %}
<h1 class="settings-title">Settings</h1>
<!-- Tab bar -->
<div class="settings-tab-bar" role="tablist">
<a class="settings-tab-btn{% if active_tab == 'overview' %} is-active{% endif %}" href="{{ url_for('main.settings', tab='overview') }}">Overview</a>
<a class="settings-tab-btn{% if active_tab == 'import' %} is-active{% endif %}" href="{{ url_for('main.settings', tab='import') }}">Import</a>
<a class="settings-tab-btn{% if active_tab == 'maintenance' %} is-active{% endif %}" href="{{ url_for('main.settings', tab='maintenance') }}">Maintenance</a>
</div>
<!-- ===== OVERVIEW TAB ===== -->
{% if active_tab == 'overview' %}
<div class="settings-tab-panel" data-tab="overview">
<!-- System Stats (Full Width at Top) -->
<div class="settings-grid-full">
<div class="settings-container">
<div class="settings-section">
<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 stats-grid-5">
<div class="stat-item">
<span class="stat-value" id="statTotalImages">-</span>
<span class="stat-label">Total Images</span>
</div>
<div class="stat-item">
<span class="stat-value" id="statTotalTags">-</span>
<span class="stat-label">Total Tags</span>
</div>
<div class="stat-item">
<span class="stat-value" id="statStorage">-</span>
<span class="stat-label">Storage Used</span>
</div>
<div class="stat-item">
<span class="stat-value" id="statPendingFiles">-</span>
<span class="stat-label">Files to Import</span>
</div>
<div class="stat-item">
<span class="stat-value" id="statPendingArchives">-</span>
<span class="stat-label">Archives to Import</span>
</div>
</div>
</div>
</div>
</div>
<!-- Import Queue Status (Full Width) -->
<div class="settings-grid-full">
<div class="settings-container">
<div class="settings-section">
<h2>Import Queue Status</h2>
<p class="settings-description">Monitor and control the Celery-based import queue system.</p>
<div id="queueStatusSection">
<div class="stats-grid stats-grid-6">
<div class="stat-item">
<span class="stat-value" id="queuePending">-</span>
<span class="stat-label">Pending</span>
</div>
<div class="stat-item">
<span class="stat-value" id="queueQueued">-</span>
<span class="stat-label">Queued</span>
</div>
<div class="stat-item">
<span class="stat-value" id="queueProcessing">-</span>
<span class="stat-label">Processing</span>
</div>
<div class="stat-item clickable-stat" id="completeStatItem" title="Click for breakdown">
<span class="stat-value" id="queueComplete">-</span>
<span class="stat-label">Complete</span>
</div>
<div class="stat-item clickable-stat" id="skippedStatItem" title="Click for breakdown">
<span class="stat-value" id="queueSkipped">-</span>
<span class="stat-label">Skipped</span>
</div>
<div class="stat-item clickable-stat" id="failedStatItem" title="Click for breakdown">
<span class="stat-value" id="queueFailed">-</span>
<span class="stat-label">Failed</span>
</div>
</div>
<!-- Age breakdown tooltip -->
<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 mt-md" style="display: none;">
<h3>Active Batch</h3>
<p>Processing <strong id="batchDir">-</strong></p>
<div class="batch-progress">
<div class="progress-bar">
<div class="progress-fill" id="batchProgressFill" style="width: 0%"></div>
</div>
<span id="batchProgressText">0 / 0</span>
</div>
</div>
<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>
<button id="clearQueueBtn" class="btn danger-btn" title="Remove all pending and queued tasks from the queue">Clear Queue</button>
</div>
<!-- Task Cleanup Controls -->
<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 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>
<option value="30">Older than 30 days</option>
</select>
<button id="clearTasksBtn" class="btn secondary-btn">Clear</button>
</div>
<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" 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>
<option value="complete">Complete</option>
<option value="processing">Processing</option>
<option value="queued">Queued</option>
<option value="pending">Pending</option>
</select>
<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" class="task-list-info">
Showing <span id="taskListShowing">0</span> of <span id="taskListTotal">0</span> tasks
</div>
<div class="tasks-table-wrapper">
<table class="tasks-table">
<thead>
<tr>
<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" class="table-message-cell">Loading...</td></tr>
</tbody>
</table>
</div>
<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 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 class="error-detail-row">
<strong>File:</strong>
<div id="errorDetailPath" class="error-detail-path"></div>
</div>
<div class="error-detail-row">
<strong>Status:</strong> <span id="errorDetailStatus"></span>
</div>
<div class="error-detail-row">
<strong>Error/Reason:</strong>
<div id="errorDetailMessage" class="error-detail-message"></div>
</div>
<div class="error-detail-meta">
<span><strong>Retries:</strong> <span id="errorDetailRetries"></span></span>
<span><strong>Created:</strong> <span id="errorDetailCreated"></span></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div><!-- end overview tab panel -->
{% endif %}
<!-- ===== IMPORT TAB ===== -->
{% if active_tab == 'import' %}
<div class="settings-tab-panel" data-tab="import">
<div class="settings-grid-full">
<div class="settings-two-col">
<!-- Column 1: Import Filters -->
<div>
<div class="settings-container">
<div class="settings-section">
<h2>Import Filters</h2>
<p class="settings-description">Configure filters to skip certain images during import.</p>
<form id="importSettingsForm" class="form-card">
<div class="form-group">
<label class="form-label">Minimum Width (px)</label>
<input type="number" id="minWidth" name="min_width" class="form-input" min="0" value="0" placeholder="0 = no minimum">
</div>
<div class="form-group">
<label class="form-label">Minimum Height (px)</label>
<input type="number" id="minHeight" name="min_height" class="form-input" min="0" value="0" placeholder="0 = no minimum">
</div>
<div class="form-group">
<label class="form-label checkbox-label">
<input type="checkbox" id="skipTransparent" name="skip_transparent">
Skip mostly transparent images
</label>
<p class="form-help">Useful for filtering out UI elements, overlays, and buttons.</p>
</div>
<div class="form-group" id="transparencyThresholdGroup" style="display: none;">
<label class="form-label">Transparency Threshold (%)</label>
<input type="number" id="transparencyThreshold" name="transparency_threshold" class="form-input" min="1" max="100" value="90">
<p class="form-help">Images with more than this % of transparent pixels will be skipped.</p>
</div>
<div class="form-group">
<label class="form-label">Duplicate Detection Sensitivity</label>
<select id="phashThreshold" name="phash_threshold" class="form-input">
<option value="0">Disabled (import all images)</option>
<option value="1">Strict (only exact visual matches)</option>
<option value="2">Normal (slight variations filtered)</option>
<option value="5">Loose (more variations allowed)</option>
<option value="10">Very Loose (only obvious duplicates filtered)</option>
</select>
<p class="form-help">Controls how similar images must be to be considered duplicates.</p>
</div>
<button type="submit" class="btn primary-btn btn-fullwidth">Save Filter Settings</button>
</form>
</div>
</div>
</div>
<!-- Column 2: Deletion Tools + Duplicate Detection -->
<div>
<div class="settings-container">
<div class="settings-section">
<h2>Delete Images by Tag</h2>
<p class="settings-description">Permanently delete all images associated with a specific tag.</p>
<div id="deleteByTagFeedback"></div>
<form id="deleteByTagForm" class="form-card">
<div class="form-group">
<label class="form-label">Select Tag to Delete</label>
<select id="deleteTagSelect" name="tag_id" class="form-input" required>
<option value="">-- Select a tag --</option>
</select>
</div>
<div id="deletePreview" class="delete-preview" style="display: none;">
<p>This will permanently delete <strong id="deleteCount">0</strong> images and their files.</p>
</div>
<div class="form-group" id="confirmationGroup" style="display: none;">
<label class="form-label">Type the tag name to confirm: <code id="confirmTagName"></code></label>
<input type="text" id="deleteConfirmInput" class="form-input" placeholder="Type tag name here..." autocomplete="off">
<p class="form-help">This action cannot be undone.</p>
</div>
<div class="form-group">
<label class="form-label checkbox-label">
<input type="checkbox" id="deleteTagAlso" name="delete_tag">
Also delete the tag itself
</label>
</div>
<button type="submit" class="btn danger-btn btn-fullwidth" disabled id="deleteByTagBtn">Delete Images</button>
</form>
</div>
</div>
<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>
<div class="form-card">
<div class="form-group">
<label class="form-label">Filter Type</label>
<select id="cleanupFilterType" class="form-input">
<option value="transparency">Transparency (mostly transparent)</option>
<option value="dimensions">Dimensions (below minimum size)</option>
</select>
</div>
<div id="transparencyOptions">
<div class="form-group">
<label class="form-label">Transparency Threshold (%)</label>
<input type="number" id="cleanupTransparencyThreshold" class="form-input" min="1" max="100" value="90">
<p class="form-help">Images with more than this % transparent pixels will be flagged.</p>
</div>
</div>
<div id="dimensionOptions" style="display: none;">
<div class="form-group">
<label class="form-label">Minimum Width (px)</label>
<input type="number" id="cleanupMinWidth" class="form-input" min="0" value="0">
</div>
<div class="form-group">
<label class="form-label">Minimum Height (px)</label>
<input type="number" id="cleanupMinHeight" class="form-input" min="0" value="0">
</div>
</div>
<button type="button" id="scanFilterBtn" class="btn primary-btn btn-fullwidth">Scan for Matching Images</button>
</div>
<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>
</div>
<div id="cleanupPreview" class="cleanup-preview"></div>
<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 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 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 mb-md">
<div class="form-group">
<label class="form-label">Hamming Distance Threshold</label>
<select id="dupThreshold" class="form-input">
<option value="5">Strict (5) - Nearly identical</option>
<option value="10" selected>Normal (10) - Minor variations</option>
<option value="15">Loose (15) - Similar images</option>
<option value="20">Very Loose (20) - Broadly similar</option>
</select>
<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 btn-fullwidth">Scan for Duplicates</button>
</div>
<div id="dupResults" style="display: none;">
<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" class="mt-md" style="display: none;">
<p class="form-help mb-sm">
Selected for deletion: <strong id="dupSelectedCount">0</strong> image(s)
<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>
</div>
</div>
</div>
</div><!-- end column 2 -->
</div><!-- end settings-two-col -->
</div><!-- end settings-grid-full -->
</div><!-- end import tab panel -->
{% endif %}
<!-- ===== MAINTENANCE TAB ===== -->
{% if active_tab == 'maintenance' %}
<div class="settings-tab-panel" data-tab="maintenance">
<div class="settings-grid-full">
<div class="settings-container">
<div class="settings-section">
<h2>Maintenance Tools</h2>
<p class="settings-description">Background operations and worker management.</p>
<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" class="mt-md">
<p class="text-muted-sm">
Workers: <strong id="workerCount">-</strong> |
Active Tasks: <strong id="activeTasks">-</strong>
</p>
</div>
</div>
<section class="settings-section">
<h3>ML tag suggestions</h3>
<p class="settings-hint">
Run inference on all images missing predictions or embeddings for the current model versions.
Resumable and safe to re-run. Backfill enqueues work on the <code>ml</code> queue; monitor progress in the ml-worker logs.
</p>
<form action="{{ url_for('main.trigger_ml_backfill') }}" method="post" onsubmit="return confirm('Enqueue ML backfill for all unprocessed images?');">
<button type="submit" class="btn secondary-btn">Run ML backfill</button>
</form>
<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.
</p>
<form action="{{ url_for('main.trigger_recompute_all_centroids') }}" method="post" onsubmit="return confirm('Enqueue centroid recompute for all eligible tags?');">
<button type="submit" class="btn secondary-btn">Recompute all centroids</button>
</form>
<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.
</p>
<form action="{{ url_for('main.trigger_sync_character_fandoms') }}" method="post" onsubmit="return confirm('Enqueue character↔fandom sync for all tagged images?');">
<button type="submit" class="btn secondary-btn">Sync character fandoms to images</button>
</form>
</section>
</div>
</div>
</div><!-- end maintenance tab panel -->
{% endif %}
<style>
.clickable-stat {
cursor: pointer;
transition: background 0.2s;
border-radius: 4px;
}
.clickable-stat:hover {
background: rgba(255, 255, 255, 0.08);
}
.cleanup-controls select {
padding: 0.4rem 0.6rem;
font-size: 0.85rem;
}
.tasks-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.tasks-table th,
.tasks-table td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.tasks-table th {
background: rgba(255, 255, 255, 0.05);
color: var(--text-dim);
font-weight: 500;
position: sticky;
top: 0;
}
.tasks-table td {
color: var(--text);
}
.tasks-table .status-pending { color: #9ca3af; }
.tasks-table .status-queued { color: #60a5fa; }
.tasks-table .status-processing { color: #fbbf24; }
.tasks-table .status-complete { color: #34d399; }
.tasks-table .status-skipped { color: #a78bfa; }
.tasks-table .status-failed { color: #f87171; }
.batch-progress {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 0.5rem;
}
.progress-bar {
flex: 1;
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--btn-primary);
transition: width 0.3s ease;
}
.queue-actions .btn {
min-width: auto;
white-space: nowrap;
}
.settings-description {
color: var(--text-muted);
margin-bottom: 1rem;
font-size: 0.9rem;
}
.form-help {
color: var(--text-muted);
font-size: 0.8rem;
margin-top: 0.25rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--btn-primary);
}
.import-stats {
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
padding: 1rem;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.import-stats h3 {
margin: 0 0 0.75rem 0;
font-size: 0.95rem;
color: var(--text-dim);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
}
.stat-value {
font-size: 1.5rem;
font-weight: 600;
color: var(--text);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-muted);
}
.delete-preview {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 1rem;
color: var(--flash-danger);
}
.cleanup-preview {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.5rem;
max-height: 400px;
overflow-y: auto;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.cleanup-item {
position: relative;
aspect-ratio: 1;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition: border-color 0.2s ease;
}
.cleanup-item.selected {
border-color: var(--btn-danger);
}
.cleanup-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cleanup-item .info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.75);
padding: 0.25rem;
font-size: 0.65rem;
color: #fff;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cleanup-item .check-overlay {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
border: 2px solid #fff;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 12px;
}
.cleanup-item.selected .check-overlay {
background: var(--btn-danger);
}
/* Feedback message styles */
.feedback-message {
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
animation: fadeInSlide 0.3s ease-out;
}
.feedback-message.success {
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.4);
color: #22c55e;
}
.feedback-message.error {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.4);
color: #ef4444;
}
.feedback-message strong {
color: inherit;
}
@keyframes fadeInSlide {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Duplicate detection styles */
.dup-group {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.dup-group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.dup-group-header h4 {
margin: 0;
font-size: 0.9rem;
color: var(--text-dim);
}
.dup-group-images {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.dup-image {
position: relative;
width: 150px;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition: border-color 0.2s ease;
}
.dup-image.selected {
border-color: var(--btn-danger);
}
.dup-image.protected {
border-color: var(--btn-primary);
cursor: default;
}
.dup-image img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
}
.dup-image-info {
background: rgba(0, 0, 0, 0.75);
padding: 0.35rem 0.5rem;
font-size: 0.7rem;
color: #fff;
}
.dup-image-info .resolution {
font-weight: 600;
}
.dup-image-info .size {
color: rgba(255, 255, 255, 0.7);
}
.dup-badge {
position: absolute;
top: 4px;
left: 4px;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.65rem;
font-weight: 600;
}
.dup-badge.keep {
background: var(--btn-primary);
color: #fff;
}
.dup-badge.delete {
background: var(--btn-danger);
color: #fff;
}
/* Error detail modal */
#errorDetailModal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
#errorDetailModal .modal-content {
background: var(--surface-elevated);
border-radius: 8px;
max-height: 80vh;
overflow-y: auto;
}
#errorDetailModal .modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
#errorDetailModal .modal-header h3 {
margin: 0;
}
#errorDetailModal .modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-muted);
}
#errorDetailModal .modal-body {
padding: 1rem;
}
.tasks-table .error-cell {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.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>
document.addEventListener('DOMContentLoaded', () => {
// ========================================
// Celery Queue Status
// ========================================
async function loadQueueStatus() {
try {
const r = await fetch('/api/import/status');
const data = await r.json();
if (data.ok) {
// Update status counts
document.getElementById('queuePending').textContent = data.status_counts.pending || 0;
document.getElementById('queueQueued').textContent = data.status_counts.queued || 0;
document.getElementById('queueProcessing').textContent = data.status_counts.processing || 0;
document.getElementById('queueComplete').textContent = data.status_counts.complete || 0;
document.getElementById('queueSkipped').textContent = data.status_counts.skipped || 0;
document.getElementById('queueFailed').textContent = data.status_counts.failed || 0;
// Update active batch info
const batchEl = document.getElementById('activeBatchInfo');
if (data.active_batch) {
batchEl.style.display = 'block';
document.getElementById('batchDir').textContent = data.active_batch.source_directory;
const processed = data.active_batch.processed_files || 0;
const total = data.active_batch.total_files || 1;
const pct = Math.round((processed / total) * 100);
document.getElementById('batchProgressFill').style.width = pct + '%';
document.getElementById('batchProgressText').textContent = `${processed} / ${total}`;
} else {
batchEl.style.display = 'none';
}
}
} catch (e) {
console.error('Failed to load queue status:', e);
}
}
// ========================================
// Task List with Filtering
// ========================================
let taskListOffset = 0;
const taskListLimit = 50;
let taskListTasks = [];
async function loadTaskList(append = false) {
const status = document.getElementById('taskStatusFilter').value;
const search = document.getElementById('taskSearchInput').value;
if (!append) {
taskListOffset = 0;
taskListTasks = [];
}
try {
const params = new URLSearchParams({
limit: taskListLimit,
offset: taskListOffset,
order: 'desc'
});
if (status) params.append('status', status);
if (search) params.append('search', search);
const r = await fetch('/api/import/tasks?' + params.toString());
const data = await r.json();
if (data.ok) {
taskListTasks = append ? [...taskListTasks, ...data.tasks] : data.tasks;
const tbody = document.getElementById('taskListBody');
if (taskListTasks.length > 0) {
tbody.innerHTML = taskListTasks.map(t => `
<tr>
<td title="${escapeHtml(t.source_path || '')}">${escapeHtml(t.filename || '-')}</td>
<td>${t.task_type === 'import_image' ? 'Image' : t.task_type === 'import_archive' ? 'Archive' : t.task_type || '-'}</td>
<td class="status-${t.status}">${t.status || '-'}</td>
<td class="error-cell" onclick="window.showErrorDetail(${t.id})" title="${t.error_message ? escapeHtml(t.error_message) : 'Click for details'}">${t.error_message ? escapeHtml(t.error_message.substring(0, 50)) + (t.error_message.length > 50 ? '...' : '') : '-'}</td>
<td>${t.completed_at ? new Date(t.completed_at).toLocaleString() : (t.created_at ? new Date(t.created_at).toLocaleString() : '-')}</td>
</tr>
`).join('');
} else {
tbody.innerHTML = '<tr><td colspan="5" class="table-message-cell">No tasks found</td></tr>';
}
// Update info
document.getElementById('taskListShowing').textContent = taskListTasks.length;
document.getElementById('taskListTotal').textContent = data.total;
// Show/hide load more button
const loadMoreBtn = document.getElementById('loadMoreTasksBtn');
if (taskListTasks.length < data.total) {
loadMoreBtn.style.display = 'inline-block';
} else {
loadMoreBtn.style.display = 'none';
}
}
} catch (e) {
console.error('Failed to load task list:', e);
}
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Store tasks for detail view
let taskDetailCache = {};
// Make showErrorDetail globally accessible for onclick handlers
window.showErrorDetail = function(taskId) {
const task = taskListTasks.find(t => t.id === taskId);
if (!task) return;
document.getElementById('errorDetailPath').textContent = task.source_path || '-';
document.getElementById('errorDetailStatus').textContent = task.status || '-';
document.getElementById('errorDetailStatus').className = 'status-' + task.status;
document.getElementById('errorDetailMessage').textContent = task.error_message || 'No error message';
document.getElementById('errorDetailRetries').textContent = task.retry_count || 0;
document.getElementById('errorDetailCreated').textContent = task.created_at ? new Date(task.created_at).toLocaleString() : '-';
document.getElementById('errorDetailModal').style.display = 'flex';
};
// Close modal on backdrop click
document.getElementById('errorDetailModal').addEventListener('click', (e) => {
if (e.target.id === 'errorDetailModal') {
e.target.style.display = 'none';
}
});
// Task list event listeners
document.getElementById('taskStatusFilter').addEventListener('change', () => loadTaskList());
document.getElementById('taskSearchBtn').addEventListener('click', () => loadTaskList());
document.getElementById('taskSearchInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') loadTaskList();
});
document.getElementById('loadMoreTasksBtn').addEventListener('click', () => {
taskListOffset += taskListLimit;
loadTaskList(true);
});
// Initial load
loadTaskList();
async function loadCeleryStatus() {
try {
const r = await fetch('/api/celery/status');
const data = await r.json();
if (data.ok) {
document.getElementById('workerCount').textContent = data.worker_count || 0;
document.getElementById('activeTasks').textContent = data.active_tasks || 0;
} else {
document.getElementById('workerCount').textContent = '?';
document.getElementById('activeTasks').textContent = '?';
}
} catch (e) {
document.getElementById('workerCount').textContent = 'offline';
document.getElementById('activeTasks').textContent = '-';
}
}
// System stats
let taskAgeStats = {};
async function loadSystemStats(triggerRefresh = false) {
try {
const url = triggerRefresh ? '/api/system/stats?refresh=true' : '/api/system/stats';
const r = await fetch(url);
const data = await r.json();
if (data.ok) {
document.getElementById('statTotalImages').textContent = data.images.total.toLocaleString();
document.getElementById('statTotalTags').textContent = data.tags.total.toLocaleString();
document.getElementById('statStorage').textContent = data.images.storage_human;
document.getElementById('statPendingFiles').textContent = data.import_folder.pending_files.toLocaleString();
document.getElementById('statPendingArchives').textContent = data.import_folder.pending_archives.toLocaleString();
// Show last updated time
const updatedEl = document.getElementById('statsUpdatedAt');
if (data.updated_at) {
const updated = new Date(data.updated_at);
const now = new Date();
const diffMs = now - updated;
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) {
updatedEl.textContent = 'Updated just now';
} else if (diffMins < 60) {
updatedEl.textContent = `Updated ${diffMins}m ago`;
} else {
const diffHours = Math.floor(diffMins / 60);
updatedEl.textContent = `Updated ${diffHours}h ago`;
}
} else if (data.message) {
updatedEl.textContent = data.message;
} else {
updatedEl.textContent = '';
}
}
} catch (e) {
console.error('Failed to load system stats:', e);
}
}
// Refresh stats button handler
document.getElementById('refreshStatsBtn').addEventListener('click', async () => {
const btn = document.getElementById('refreshStatsBtn');
btn.disabled = true;
btn.textContent = 'Refreshing...';
// Trigger refresh and reload after a short delay
await loadSystemStats(true);
await new Promise(r => setTimeout(r, 2000)); // Wait for task to run
await loadSystemStats();
btn.disabled = false;
btn.textContent = 'Refresh';
});
async function loadTaskAgeStats() {
try {
const r = await fetch('/api/import/task-stats');
const data = await r.json();
if (data.ok) {
taskAgeStats = data.stats;
}
} catch (e) {
console.error('Failed to load task age stats:', e);
}
}
function showAgeBreakdown(status) {
const stats = taskAgeStats[status];
if (!stats) return;
const breakdown = document.getElementById('ageBreakdown');
const title = status.charAt(0).toUpperCase() + status.slice(1);
document.getElementById('ageBreakdownTitle').textContent = title;
document.getElementById('ageBreakdownToday').textContent = stats.today;
document.getElementById('ageBreakdownWeek').textContent = stats.this_week;
document.getElementById('ageBreakdownOlder').textContent = stats.older;
breakdown.style.display = 'block';
}
// Add click handlers for stat items
document.getElementById('completeStatItem').addEventListener('click', () => showAgeBreakdown('complete'));
document.getElementById('skippedStatItem').addEventListener('click', () => showAgeBreakdown('skipped'));
document.getElementById('failedStatItem').addEventListener('click', () => showAgeBreakdown('failed'));
// Load queue status on page load and refresh periodically
loadQueueStatus();
loadCeleryStatus();
loadSystemStats();
loadTaskAgeStats();
setInterval(loadQueueStatus, 5000); // Refresh every 5 seconds
setInterval(loadCeleryStatus, 10000); // Refresh every 10 seconds
setInterval(loadSystemStats, 30000); // Refresh every 30 seconds
setInterval(loadTaskAgeStats, 30000); // Refresh every 30 seconds
// Quick Scan (default)
document.getElementById('triggerImportBtn').addEventListener('click', async () => {
const btn = document.getElementById('triggerImportBtn');
btn.disabled = true;
btn.textContent = 'Starting...';
try {
const r = await fetch('/api/import/trigger', { method: 'POST' });
const data = await r.json();
if (data.ok) {
alert('Quick scan started! Task ID: ' + data.task_id);
loadQueueStatus();
} else {
alert('Failed to start import: ' + (data.error || 'Unknown error'));
}
} catch (e) {
alert('Failed to start import: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Quick Scan';
}
});
// Deep Scan (full reprocessing)
document.getElementById('triggerDeepScanBtn').addEventListener('click', async () => {
if (!confirm('Deep scan will reprocess ALL files in the import folder.\n\nThis includes:\n- Full pHash similarity detection\n- Archive tag merging for duplicates\n- Thumbnail regeneration\n- Sidecar metadata re-application\n\nThis may take a long time. Continue?')) {
return;
}
const btn = document.getElementById('triggerDeepScanBtn');
btn.disabled = true;
btn.textContent = 'Starting...';
try {
const formData = new FormData();
formData.append('deep', 'true');
const r = await fetch('/api/import/trigger', { method: 'POST', body: formData });
const data = await r.json();
if (data.ok) {
alert('Deep scan started! Task ID: ' + data.task_id + '\n\nThis will reprocess all files and may take a while.');
loadQueueStatus();
} else {
alert('Failed to start deep scan: ' + (data.error || 'Unknown error'));
}
} catch (e) {
alert('Failed to start deep scan: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Deep Scan';
}
});
// Retry Failed Tasks
document.getElementById('retryFailedBtn').addEventListener('click', async () => {
const btn = document.getElementById('retryFailedBtn');
btn.disabled = true;
btn.textContent = 'Retrying...';
try {
const r = await fetch('/api/import/retry-failed', { method: 'POST' });
const data = await r.json();
if (data.ok) {
alert(`Retried ${data.retried} failed task(s).`);
loadQueueStatus();
} else {
alert('Failed to retry: ' + (data.error || 'Unknown error'));
}
} catch (e) {
alert('Failed to retry: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Retry Failed Tasks';
}
});
// Clear Queue (pending/queued tasks)
document.getElementById('clearQueueBtn').addEventListener('click', async () => {
const pendingCount = parseInt(document.getElementById('queuePending').textContent) || 0;
const queuedCount = parseInt(document.getElementById('queueQueued').textContent) || 0;
const totalQueued = pendingCount + queuedCount;
if (totalQueued === 0) {
alert('No pending or queued tasks to clear.');
return;
}
if (!confirm(`Are you sure you want to clear ${totalQueued} pending/queued task(s) from the import queue?\n\nThis will stop these tasks from being processed.`)) {
return;
}
const btn = document.getElementById('clearQueueBtn');
btn.disabled = true;
btn.textContent = 'Clearing...';
try {
const r = await fetch('/api/import/clear-queue', { method: 'POST' });
const data = await r.json();
if (data.ok) {
alert(`Cleared ${data.deleted} task(s) from the queue.`);
loadQueueStatus();
loadTaskList();
} else {
alert('Failed to clear queue: ' + (data.error || 'Unknown error'));
}
} catch (e) {
alert('Failed to clear queue: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Clear Queue';
}
});
// Clear Tasks with age/status options
document.getElementById('clearTasksBtn').addEventListener('click', async () => {
const btn = document.getElementById('clearTasksBtn');
const statuses = document.getElementById('clearStatusSelect').value;
const olderThanDays = document.getElementById('clearAgeSelect').value;
const statusLabel = document.getElementById('clearStatusSelect').selectedOptions[0].text;
const ageLabel = document.getElementById('clearAgeSelect').selectedOptions[0].text;
if (!confirm(`Clear ${statusLabel} tasks (${ageLabel.toLowerCase()})?`)) {
return;
}
btn.disabled = true;
btn.textContent = 'Clearing...';
try {
const formData = new FormData();
formData.append('statuses', statuses);
formData.append('older_than_days', olderThanDays);
const r = await fetch('/api/import/clear-completed', { method: 'POST', body: formData });
const data = await r.json();
if (data.ok) {
alert(`Cleared ${data.deleted} task(s).`);
loadQueueStatus();
loadTaskAgeStats();
} else {
alert('Failed to clear: ' + (data.error || 'Unknown error'));
}
} catch (e) {
alert('Failed to clear: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Clear';
}
});
// Regenerate Missing Thumbnails
document.getElementById('regenMissingThumbsBtn').addEventListener('click', async () => {
const btn = document.getElementById('regenMissingThumbsBtn');
btn.disabled = true;
btn.textContent = 'Starting...';
try {
const r = await fetch('/api/thumbnails/regenerate-missing', { method: 'POST' });
const data = await r.json();
if (data.ok) {
alert('Missing thumbnail generation started! Task ID: ' + data.task_id);
} else {
alert('Failed to start: ' + (data.error || 'Unknown error'));
}
} catch (e) {
alert('Failed to start: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Regenerate Missing Thumbnails';
}
});
// Reapply Artist Tags from Paths
document.getElementById('reapplyArtistTagsBtn').addEventListener('click', async () => {
const btn = document.getElementById('reapplyArtistTagsBtn');
if (!confirm('This will re-apply artist tags to all images based on their file paths. Continue?')) {
return;
}
btn.disabled = true;
btn.textContent = 'Processing...';
try {
const r = await fetch('/api/reapply-artist-tags', { method: 'POST' });
const data = await r.json();
if (data.ok) {
alert(`Artist tags reapplied!\n\nUpdated: ${data.updated}\nSkipped: ${data.skipped}\nTotal images: ${data.total}`);
} else {
alert('Failed: ' + (data.error || 'Unknown error'));
}
} catch (e) {
alert('Failed: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Reapply Artist Tags from Paths';
}
});
// Cleanup Orphaned Tags
document.getElementById('cleanupOrphanedTagsBtn').addEventListener('click', async () => {
const btn = document.getElementById('cleanupOrphanedTagsBtn');
if (!confirm('This will delete all tags that are not attached to any images. Continue?')) {
return;
}
btn.disabled = true;
btn.textContent = 'Cleaning up...';
try {
const r = await fetch('/api/cleanup-orphaned-tags', { method: 'POST' });
const data = await r.json();
if (data.ok) {
if (data.deleted_count === 0) {
alert('No orphaned tags found.');
} else {
const tagList = data.deleted_tags.map(t => `${t.name} (${t.kind || 'user'})`).join('\n');
alert(`Deleted ${data.deleted_count} orphaned tag(s):\n\n${tagList}`);
}
// Refresh the tag dropdown if visible
loadTagsForDeletion();
} else {
alert('Failed: ' + (data.error || 'Unknown error'));
}
} catch (e) {
alert('Failed: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Cleanup Orphaned Tags';
}
});
// Load import settings
fetch('/api/import-settings')
.then(r => r.json())
.then(data => {
if (data.ok) {
document.getElementById('minWidth').value = data.settings.min_width || 0;
document.getElementById('minHeight').value = data.settings.min_height || 0;
document.getElementById('skipTransparent').checked = data.settings.skip_transparent || false;
document.getElementById('transparencyThreshold').value = (data.settings.transparency_threshold || 0.9) * 100;
document.getElementById('phashThreshold').value = data.settings.phash_threshold ?? 2;
toggleTransparencyThreshold();
}
});
// Load tags for deletion dropdown
loadTagsForDeletion();
// Toggle transparency threshold visibility
document.getElementById('skipTransparent').addEventListener('change', toggleTransparencyThreshold);
function toggleTransparencyThreshold() {
const show = document.getElementById('skipTransparent').checked;
document.getElementById('transparencyThresholdGroup').style.display = show ? 'block' : 'none';
}
// Save import settings
document.getElementById('importSettingsForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData();
fd.append('min_width', document.getElementById('minWidth').value);
fd.append('min_height', document.getElementById('minHeight').value);
fd.append('skip_transparent', document.getElementById('skipTransparent').checked ? 'true' : 'false');
fd.append('transparency_threshold', document.getElementById('transparencyThreshold').value / 100);
fd.append('phash_threshold', document.getElementById('phashThreshold').value);
const r = await fetch('/api/import-settings', { method: 'POST', body: fd });
const data = await r.json();
if (data.ok) {
alert('Settings saved successfully!');
} else {
alert('Failed to save settings: ' + (data.error || 'Unknown error'));
}
});
// Tag selection for deletion
let selectedTagName = '';
document.getElementById('deleteTagSelect').addEventListener('change', async (e) => {
const tagId = e.target.value;
const previewEl = document.getElementById('deletePreview');
const countEl = document.getElementById('deleteCount');
const confirmGroup = document.getElementById('confirmationGroup');
const confirmInput = document.getElementById('deleteConfirmInput');
const confirmTagName = document.getElementById('confirmTagName');
const btn = document.getElementById('deleteByTagBtn');
// Reset state
confirmInput.value = '';
btn.disabled = true;
selectedTagName = '';
if (!tagId) {
previewEl.style.display = 'none';
confirmGroup.style.display = 'none';
return;
}
const r = await fetch(`/api/tag/${tagId}/image-count`);
const data = await r.json();
if (data.ok) {
countEl.textContent = data.count;
selectedTagName = data.tag_name;
confirmTagName.textContent = selectedTagName;
previewEl.style.display = 'block';
confirmGroup.style.display = data.count > 0 ? 'block' : 'none';
}
});
// Enable delete button only when confirmation matches
document.getElementById('deleteConfirmInput').addEventListener('input', (e) => {
const btn = document.getElementById('deleteByTagBtn');
const typed = e.target.value.trim();
btn.disabled = typed !== selectedTagName;
});
// Helper to show feedback message
function showDeleteByTagFeedback(message, isSuccess) {
const feedbackEl = document.getElementById('deleteByTagFeedback');
feedbackEl.innerHTML = `<div class="feedback-message ${isSuccess ? 'success' : 'error'}">${message}</div>`;
// Scroll feedback into view
feedbackEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function clearDeleteByTagFeedback() {
document.getElementById('deleteByTagFeedback').innerHTML = '';
}
// Delete by tag form
document.getElementById('deleteByTagForm').addEventListener('submit', async (e) => {
e.preventDefault();
const tagId = document.getElementById('deleteTagSelect').value;
const deleteTag = document.getElementById('deleteTagAlso').checked;
const count = document.getElementById('deleteCount').textContent;
const confirmInput = document.getElementById('deleteConfirmInput');
const btn = document.getElementById('deleteByTagBtn');
const previewEl = document.getElementById('deletePreview');
// Clear any previous feedback
clearDeleteByTagFeedback();
// Double-check confirmation matches (defense in depth)
if (confirmInput.value.trim() !== selectedTagName) {
showDeleteByTagFeedback('Tag name confirmation does not match.', false);
return;
}
// Show loading state
const originalBtnText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Deleting...';
const fd = new FormData();
fd.append('tag_id', tagId);
fd.append('tag_name', selectedTagName); // Server validates this matches the ID
fd.append('delete_tag', deleteTag ? 'true' : 'false');
try {
const r = await fetch('/api/delete-by-tag', { method: 'POST', body: fd });
const data = await r.json();
if (data.ok) {
// Show success message in the dedicated feedback area
let successMsg = `Successfully deleted <strong>${data.deleted_count}</strong> image(s).`;
if (data.tag_deleted) successMsg += ' Tag was also deleted.';
if (data.errors && data.errors.length > 0) {
successMsg += `<br><small>Some errors occurred: ${data.errors.join(', ')}</small>`;
}
showDeleteByTagFeedback(successMsg, true);
// Reload tag list
await loadTagsForDeletion();
// Reset form immediately since feedback is now in a separate area
document.getElementById('deleteTagSelect').value = '';
previewEl.style.display = 'none';
document.getElementById('confirmationGroup').style.display = 'none';
confirmInput.value = '';
document.getElementById('deleteTagAlso').checked = false;
selectedTagName = '';
btn.textContent = originalBtnText;
} else {
// Show error in feedback area
showDeleteByTagFeedback(`Failed to delete images: <strong>${data.error || 'Unknown error'}</strong>`, false);
btn.disabled = false;
btn.textContent = originalBtnText;
}
} catch (err) {
// Network or parsing error
showDeleteByTagFeedback(`Request failed: <strong>${err.message}</strong>`, false);
btn.disabled = false;
btn.textContent = originalBtnText;
}
});
async function loadTagsForDeletion() {
const select = document.getElementById('deleteTagSelect');
select.innerHTML = '<option value="">-- Select a tag --</option>';
// Get all tags (with IDs) from the API
const r = await fetch('/api/tags/search?limit=500');
const data = await r.json();
if (data.tags) {
// Group by kind
const grouped = {};
data.tags.forEach(t => {
const kind = t.kind || 'user';
if (!grouped[kind]) grouped[kind] = [];
grouped[kind].push(t);
});
// Add to select with optgroups
Object.keys(grouped).sort().forEach(kind => {
const optgroup = document.createElement('optgroup');
optgroup.label = kind.charAt(0).toUpperCase() + kind.slice(1) + ' Tags';
grouped[kind].sort((a, b) => a.name.localeCompare(b.name)).forEach(tag => {
const opt = document.createElement('option');
opt.value = tag.id; // Use tag ID as value
opt.textContent = tag.name;
optgroup.appendChild(opt);
});
select.appendChild(optgroup);
});
}
}
// ========================================
// Image Cleanup / Filter Tool
// ========================================
let cleanupImages = [];
let selectedForDeletion = new Set();
// Toggle filter options visibility
document.getElementById('cleanupFilterType').addEventListener('change', () => {
const type = document.getElementById('cleanupFilterType').value;
document.getElementById('transparencyOptions').style.display = type === 'transparency' ? 'block' : 'none';
document.getElementById('dimensionOptions').style.display = type === 'dimensions' ? 'block' : 'none';
// Reset results when changing filter type
document.getElementById('cleanupResults').style.display = 'none';
cleanupImages = [];
selectedForDeletion.clear();
});
// Scan for filterable images
document.getElementById('scanFilterBtn').addEventListener('click', async () => {
const btn = document.getElementById('scanFilterBtn');
const filterType = document.getElementById('cleanupFilterType').value;
btn.disabled = true;
btn.textContent = 'Scanning...';
let url = `/api/filter-scan?filter_type=${filterType}&limit=100`;
if (filterType === 'transparency') {
const threshold = document.getElementById('cleanupTransparencyThreshold').value / 100;
url += `&threshold=${threshold}`;
} else {
const minWidth = document.getElementById('cleanupMinWidth').value || 0;
const minHeight = document.getElementById('cleanupMinHeight').value || 0;
url += `&min_width=${minWidth}&min_height=${minHeight}`;
}
try {
const r = await fetch(url);
const data = await r.json();
if (data.ok) {
cleanupImages = data.images;
selectedForDeletion.clear();
renderCleanupResults();
} else {
alert('Scan failed: ' + (data.error || 'Unknown error'));
}
} catch (e) {
alert('Scan failed: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Scan for Matching Images';
}
});
function renderCleanupResults() {
const resultsEl = document.getElementById('cleanupResults');
const countEl = document.getElementById('cleanupCount');
const previewEl = document.getElementById('cleanupPreview');
const actionsEl = document.getElementById('cleanupActions');
const filterType = document.getElementById('cleanupFilterType').value;
countEl.textContent = cleanupImages.length;
resultsEl.style.display = 'block';
if (cleanupImages.length === 0) {
previewEl.innerHTML = '<p class="empty-state-centered">No images match the filter criteria.</p>';
actionsEl.style.display = 'none';
return;
}
actionsEl.style.display = 'block';
previewEl.innerHTML = cleanupImages.map(img => {
const info = filterType === 'dimensions'
? `${img.width}x${img.height}`
: img.filename.substring(0, 15);
return `
<div class="cleanup-item" data-id="${img.id}">
<img src="${img.thumb_url}" alt="${img.filename}" loading="lazy">
<div class="info">${info}</div>
<div class="check-overlay"></div>
</div>
`;
}).join('');
// Add click handlers for selection
previewEl.querySelectorAll('.cleanup-item').forEach(item => {
item.addEventListener('click', () => {
const id = parseInt(item.dataset.id);
if (selectedForDeletion.has(id)) {
selectedForDeletion.delete(id);
item.classList.remove('selected');
} else {
selectedForDeletion.add(id);
item.classList.add('selected');
}
updateSelectedCount();
});
});
updateSelectedCount();
}
function updateSelectedCount() {
const countEl = document.getElementById('cleanupSelectedCount');
const btn = document.getElementById('deleteSelectedBtn');
const selectAllEl = document.getElementById('cleanupSelectAll');
countEl.textContent = selectedForDeletion.size;
btn.disabled = selectedForDeletion.size === 0;
// Update select all checkbox state
selectAllEl.checked = selectedForDeletion.size === cleanupImages.length && cleanupImages.length > 0;
selectAllEl.indeterminate = selectedForDeletion.size > 0 && selectedForDeletion.size < cleanupImages.length;
}
// Select all toggle
document.getElementById('cleanupSelectAll').addEventListener('change', (e) => {
const checked = e.target.checked;
const previewEl = document.getElementById('cleanupPreview');
cleanupImages.forEach(img => {
if (checked) {
selectedForDeletion.add(img.id);
} else {
selectedForDeletion.delete(img.id);
}
});
previewEl.querySelectorAll('.cleanup-item').forEach(item => {
item.classList.toggle('selected', checked);
});
updateSelectedCount();
});
// Delete selected images
document.getElementById('deleteSelectedBtn').addEventListener('click', async () => {
if (selectedForDeletion.size === 0) return;
const confirmMsg = `Are you sure you want to permanently delete ${selectedForDeletion.size} image(s)? This cannot be undone.`;
if (!confirm(confirmMsg)) return;
const btn = document.getElementById('deleteSelectedBtn');
btn.disabled = true;
btn.textContent = 'Deleting...';
const fd = new FormData();
fd.append('image_ids', Array.from(selectedForDeletion).join(','));
fd.append('confirm', 'true');
try {
const r = await fetch('/api/filter-delete', { method: 'POST', body: fd });
const data = await r.json();
if (data.ok) {
alert(`Successfully deleted ${data.deleted_count} image(s).`);
// Remove deleted images from the list
cleanupImages = cleanupImages.filter(img => !selectedForDeletion.has(img.id));
selectedForDeletion.clear();
renderCleanupResults();
} else {
alert('Delete failed: ' + (data.error || 'Unknown error'));
}
} catch (e) {
alert('Delete failed: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Delete Selected Images';
}
});
// ========================================
// Duplicate Detection
// ========================================
let duplicateGroups = [];
let selectedDuplicates = new Set();
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
document.getElementById('scanDuplicatesBtn').addEventListener('click', async () => {
const btn = document.getElementById('scanDuplicatesBtn');
const threshold = document.getElementById('dupThreshold').value;
btn.disabled = true;
btn.textContent = 'Scanning...';
const fd = new FormData();
fd.append('threshold', threshold);
fd.append('limit', '50');
try {
const r = await fetch('/api/duplicates/scan', { method: 'POST', body: fd });
const data = await r.json();
if (data.ok) {
duplicateGroups = data.groups;
selectedDuplicates.clear();
renderDuplicateResults();
} else {
alert('Scan failed: ' + (data.error || 'Unknown error'));
}
} catch (e) {
alert('Scan failed: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Scan for Duplicates';
}
});
function renderDuplicateResults() {
const resultsEl = document.getElementById('dupResults');
const countEl = document.getElementById('dupGroupCount');
const groupsEl = document.getElementById('dupGroups');
const actionsEl = document.getElementById('dupActions');
countEl.textContent = duplicateGroups.length;
resultsEl.style.display = 'block';
if (duplicateGroups.length === 0) {
groupsEl.innerHTML = '<p class="empty-state-centered">No duplicate images found.</p>';
actionsEl.style.display = 'none';
return;
}
actionsEl.style.display = 'block';
// Auto-select all non-first images for deletion
duplicateGroups.forEach((group, groupIdx) => {
group.forEach((img, imgIdx) => {
if (imgIdx > 0) {
selectedDuplicates.add(img.id);
}
});
});
groupsEl.innerHTML = duplicateGroups.map((group, groupIdx) => `
<div class="dup-group" data-group="${groupIdx}">
<div class="dup-group-header">
<h4>Group ${groupIdx + 1} (${group.length} images)</h4>
<button type="button" class="btn secondary-btn btn-sm" onclick="toggleGroupSelection(${groupIdx})">Toggle Selection</button>
</div>
<div class="dup-group-images">
${group.map((img, imgIdx) => `
<div class="dup-image ${imgIdx === 0 ? 'protected' : 'selected'}"
data-id="${img.id}"
data-group="${groupIdx}"
data-protected="${imgIdx === 0}">
<img src="${img.thumb_url}" alt="${img.filename}" loading="lazy">
<span class="dup-badge ${imgIdx === 0 ? 'keep' : 'delete'}">${imgIdx === 0 ? 'KEEP' : 'DELETE'}</span>
<div class="dup-image-info">
<div class="resolution">${img.width}x${img.height}</div>
<div class="size">${formatFileSize(img.file_size)}</div>
</div>
</div>
`).join('')}
</div>
</div>
`).join('');
// Add click handlers
groupsEl.querySelectorAll('.dup-image:not(.protected)').forEach(item => {
item.addEventListener('click', () => {
const id = parseInt(item.dataset.id);
const badge = item.querySelector('.dup-badge');
if (selectedDuplicates.has(id)) {
selectedDuplicates.delete(id);
item.classList.remove('selected');
badge.textContent = 'KEEP';
badge.classList.remove('delete');
badge.classList.add('keep');
} else {
selectedDuplicates.add(id);
item.classList.add('selected');
badge.textContent = 'DELETE';
badge.classList.remove('keep');
badge.classList.add('delete');
}
updateDupSelectedCount();
});
});
updateDupSelectedCount();
}
window.toggleGroupSelection = function(groupIdx) {
const group = duplicateGroups[groupIdx];
const groupEl = document.querySelector(`.dup-group[data-group="${groupIdx}"]`);
// Check if any non-protected images are selected
const nonProtected = group.slice(1);
const allSelected = nonProtected.every(img => selectedDuplicates.has(img.id));
nonProtected.forEach(img => {
const itemEl = groupEl.querySelector(`.dup-image[data-id="${img.id}"]`);
const badge = itemEl.querySelector('.dup-badge');
if (allSelected) {
selectedDuplicates.delete(img.id);
itemEl.classList.remove('selected');
badge.textContent = 'KEEP';
badge.classList.remove('delete');
badge.classList.add('keep');
} else {
selectedDuplicates.add(img.id);
itemEl.classList.add('selected');
badge.textContent = 'DELETE';
badge.classList.remove('keep');
badge.classList.add('delete');
}
});
updateDupSelectedCount();
};
function updateDupSelectedCount() {
const countEl = document.getElementById('dupSelectedCount');
const btn = document.getElementById('deleteDuplicatesBtn');
countEl.textContent = selectedDuplicates.size;
btn.disabled = selectedDuplicates.size === 0;
}
document.getElementById('deleteDuplicatesBtn').addEventListener('click', async () => {
if (selectedDuplicates.size === 0) return;
const confirmMsg = `Are you sure you want to permanently delete ${selectedDuplicates.size} duplicate image(s)? This cannot be undone.`;
if (!confirm(confirmMsg)) return;
const btn = document.getElementById('deleteDuplicatesBtn');
btn.disabled = true;
btn.textContent = 'Deleting...';
try {
const r = await fetch('/api/duplicates/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_ids: Array.from(selectedDuplicates) })
});
const data = await r.json();
if (data.ok) {
alert(`Successfully deleted ${data.deleted_count} duplicate image(s).`);
// Remove deleted images from groups
duplicateGroups = duplicateGroups.map(group =>
group.filter(img => !selectedDuplicates.has(img.id))
).filter(group => group.length > 1); // Remove groups with only 1 image left
selectedDuplicates.clear();
renderDuplicateResults();
} else {
alert('Delete failed: ' + (data.error || 'Unknown error'));
}
} catch (e) {
alert('Delete failed: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Delete Selected Duplicates';
}
});
});
</script>
{% endblock %}