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
+30
View File
@@ -95,6 +95,8 @@ def random_images_api():
'thumb_url': url_for('main.serve_image', filename=thumb_path),
'full_url': url_for('main.serve_image', filename=full_path),
'is_video': is_video,
'width': img.width,
'height': img.height,
'date': (img.taken_at or img.imported_at).strftime('%Y-%m-%d') if (img.taken_at or img.imported_at) else '',
'tags': [{'name': t.name, 'kind': t.kind} for t in img.tags]
})
@@ -714,6 +716,34 @@ def get_image_suggestions(image_id):
return jsonify({'ok': True, 'suggestions': suggestions})
@main.post("/api/suggestions/bulk")
def get_bulk_suggestions_api():
"""Consensus ML suggestions across a selection of images.
Body: {"image_ids": [...], "threshold": 0.8}
Returns grouped suggestions where each tag was suggested for or already
applied to at least `threshold` fraction of the selected images.
"""
from app.services.tag_suggestions import get_bulk_suggestions
payload = request.get_json(force=True, silent=True) or {}
image_ids = payload.get('image_ids') or []
try:
image_ids = [int(x) for x in image_ids]
except (TypeError, ValueError):
return jsonify({'ok': False, 'error': 'invalid_image_ids'}), 400
if not image_ids:
return jsonify({'ok': False, 'error': 'no_image_ids'}), 400
try:
threshold = float(payload.get('threshold', 0.8))
except (TypeError, ValueError):
threshold = 0.8
threshold = max(0.0, min(1.0, threshold))
suggestions = get_bulk_suggestions(image_ids, threshold=threshold)
return jsonify({'ok': True, 'suggestions': suggestions, 'evaluated': len(image_ids), 'threshold': threshold})
@main.post("/image/<int:image_id>/suggestions/accept")
def accept_image_suggestion(image_id):
from app.models import SuggestionFeedback
+104 -1
View File
@@ -197,7 +197,13 @@ def _merge(wd14: list[dict], embedding: list[dict]) -> list[dict]:
return list(by_name.values())
def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict:
def get_suggestions(
image_id: int,
top_k_per_category: int = 10,
*,
cfg: dict | None = None,
existing_all: set[str] | None = None,
) -> dict:
"""Return suggestions grouped by category for one image.
Shape:
@@ -207,10 +213,14 @@ def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict:
'general': [...],
}
Ordered by confidence desc within each category. 'meta' and 'rating' are omitted.
The optional `cfg` and `existing_all` kwargs let callers that loop over many
images amortize the config fetch and the Tag-table scan.
"""
if ImageRecord.query.get(image_id) is None:
return {'character': [], 'copyright': [], 'general': []}
if cfg is None:
cfg = _config()
already = _existing_tag_names_for_image(image_id)
@@ -219,6 +229,7 @@ def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict:
_embedding_tag_suggestions(image_id, cfg, already),
)
if existing_all is None:
existing_all = _existing_tag_names()
grouped: dict[str, list[dict]] = {'character': [], 'copyright': [], 'general': []}
@@ -233,3 +244,95 @@ def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict:
grouped[cat] = grouped[cat][:top_k_per_category]
return grouped
def get_bulk_suggestions(
image_ids: list[int],
threshold: float = 0.8,
top_k_per_category: int = 10,
) -> dict:
"""Return consensus suggestions across multiple selected images.
A tag is included only if it was suggested for (or already applied to)
at least `threshold` fraction of the selected images. Confidence returned
is the mean over the images where it was actually suggested; images that
already have the tag contribute to coverage but not to confidence.
Shape matches get_suggestions(): {'character': [...], 'copyright': [...], 'general': [...]}.
Each item also carries 'coverage' (float in [0, 1]) and 'covered_count' (int)
so the UI can display how broad the consensus is.
"""
if not image_ids:
return {'character': [], 'copyright': [], 'general': []}
cfg = _config()
existing_all = _existing_tag_names()
total = len(image_ids)
# Batched lookup: which selected images already have which tags. Used so a
# tag that's already on most of the selection isn't penalized just because
# it drops out of those images' suggestion lists.
existing_by_image: dict[int, set[str]] = {iid: set() for iid in image_ids}
rows = (
db.session.query(image_tags.c.image_id, Tag.name)
.join(Tag, Tag.id == image_tags.c.tag_id)
.filter(image_tags.c.image_id.in_(image_ids))
.all()
)
for iid, name in rows:
existing_by_image.setdefault(iid, set()).add(name)
# Aggregate suggested-count and confidence per tag name.
# suggested_count and existing_count are disjoint because get_suggestions()
# excludes tags the image already has.
tag_stats: dict[str, dict] = {}
for image_id in image_ids:
grouped = get_suggestions(
image_id,
top_k_per_category=top_k_per_category,
cfg=cfg,
existing_all=existing_all,
)
for category, items in grouped.items():
for item in items:
name = item['name']
stat = tag_stats.get(name)
if stat is None:
stat = {
'name': name,
'category': category,
'suggested_count': 0,
'sum_conf': 0.0,
'source': item['source'],
}
tag_stats[name] = stat
stat['suggested_count'] += 1
stat['sum_conf'] += item['confidence']
# Only tags that appeared somewhere as suggestions are candidates. Tags
# already universally applied don't appear here — correctly, since there's
# nothing for Accept to do.
result: dict[str, list[dict]] = {'character': [], 'copyright': [], 'general': []}
for stat in tag_stats.values():
existing_count = sum(
1 for iid in image_ids if stat['name'] in existing_by_image.get(iid, set())
)
covered_count = stat['suggested_count'] + existing_count
coverage = covered_count / total
if coverage < threshold:
continue
mean_conf = stat['sum_conf'] / stat['suggested_count']
result[stat['category']].append({
'name': stat['name'],
'category': stat['category'],
'confidence': mean_conf,
'coverage': coverage,
'covered_count': covered_count,
'source': stat['source'],
'exists_in_db': stat['name'] in existing_all,
})
for cat in result:
result[cat].sort(key=lambda x: x['confidence'], reverse=True)
result[cat] = result[cat][:top_k_per_category]
return result
+131
View File
@@ -32,6 +32,8 @@
dom.tagInput = document.getElementById('bulkTagInput');
dom.autocomplete = document.getElementById('bulkTagAutocomplete');
dom.commonTags = document.getElementById('commonTagsList');
dom.bulkSuggestionsList = document.getElementById('bulkSuggestionsList');
dom.bulkSuggestionsHint = document.getElementById('bulkSuggestionsHint');
if (!dom.selectBtn || !dom.panel) return;
@@ -124,6 +126,7 @@
updateAllOrderBadges();
updateCount();
loadCommonTags();
loadBulkSuggestions();
dispatchSelectionChanged();
}
@@ -137,6 +140,7 @@
});
updateCount();
updateCommonTagsDisplay();
clearBulkSuggestions();
dispatchSelectionChanged();
}
@@ -288,6 +292,133 @@
});
}
// ---------------------------
// Bulk ML suggestions (consensus across selection)
// ---------------------------
const BULK_SUGGESTIONS_MIN = 2;
const BULK_SUGGESTIONS_THRESHOLD = 0.8;
const BULK_SUGGESTIONS_CATEGORY_ORDER = ['character', 'copyright', 'general'];
const BULK_SUGGESTIONS_CATEGORY_LABEL = {
character: 'Characters',
copyright: 'Fandoms',
general: 'General',
};
function clearBulkSuggestions() {
if (dom.bulkSuggestionsList) dom.bulkSuggestionsList.innerHTML = '';
setBulkSuggestionsHint('Select 2+ images to see consensus suggestions.');
}
function setBulkSuggestionsHint(text, visible = true) {
if (!dom.bulkSuggestionsHint) return;
dom.bulkSuggestionsHint.textContent = text;
dom.bulkSuggestionsHint.style.display = visible ? '' : 'none';
}
async function loadBulkSuggestions() {
if (!dom.bulkSuggestionsList) return;
const ids = Array.from(state.selectedIds);
if (ids.length < BULK_SUGGESTIONS_MIN) {
clearBulkSuggestions();
return;
}
setBulkSuggestionsHint('Loading…');
dom.bulkSuggestionsList.innerHTML = '';
try {
const r = await fetch('/api/suggestions/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_ids: ids, threshold: BULK_SUGGESTIONS_THRESHOLD }),
});
const j = await r.json();
if (!j.ok) {
setBulkSuggestionsHint('Could not load suggestions.');
return;
}
renderBulkSuggestions(j.suggestions || {}, ids.length);
} catch (e) {
setBulkSuggestionsHint('Could not load suggestions.');
}
}
function renderBulkSuggestions(grouped, selectionSize) {
if (!dom.bulkSuggestionsList) return;
dom.bulkSuggestionsList.innerHTML = '';
let total = 0;
for (const cat of BULK_SUGGESTIONS_CATEGORY_ORDER) {
const items = (grouped && grouped[cat]) || [];
if (items.length === 0) continue;
total += items.length;
const group = document.createElement('div');
group.className = 'suggestions-category';
group.dataset.category = cat;
const label = document.createElement('div');
label.className = 'suggestions-category-label';
label.textContent = BULK_SUGGESTIONS_CATEGORY_LABEL[cat] || cat;
group.appendChild(label);
const chips = document.createElement('div');
chips.className = 'suggestions-chips';
for (const item of items) {
chips.appendChild(buildBulkSuggestionChip(item, selectionSize));
}
group.appendChild(chips);
dom.bulkSuggestionsList.appendChild(group);
}
if (total === 0) {
setBulkSuggestionsHint('No consensus suggestions for this selection.');
} else {
setBulkSuggestionsHint('', false);
}
}
function buildBulkSuggestionChip(item, selectionSize) {
const chip = document.createElement('div');
chip.className = 'suggestion-chip suggestion-chip-bulk';
chip.dataset.tagName = item.name;
chip.dataset.category = item.category;
const pct = Math.round((item.confidence || 0) * 100);
const covered = item.covered_count != null ? item.covered_count : selectionSize;
chip.title = `${item.name} — suggested for ${covered}/${selectionSize}`;
chip.innerHTML = `
<button type="button" class="sugg-btn sugg-accept" aria-label="Add to all selected">✓</button>
<span class="sugg-body">
<span class="sugg-label">${escapeHtml(item.name)}</span>
<span class="sugg-confidence">${pct}% · ${covered}/${selectionSize}</span>
</span>
`;
const acceptBtn = chip.querySelector('.sugg-accept');
if (acceptBtn) {
acceptBtn.addEventListener('click', () => acceptBulkSuggestion(chip));
}
return chip;
}
async function acceptBulkSuggestion(chip) {
if (chip.dataset.disabled === 'true') return;
chip.dataset.disabled = 'true';
chip.style.pointerEvents = 'none';
chip.style.opacity = '0.5';
const name = chip.dataset.tagName;
try {
await bulkAddTag(name);
chip.classList.add('leaving');
setTimeout(() => chip.remove(), 200);
} catch {
chip.dataset.disabled = 'false';
chip.style.pointerEvents = '';
chip.style.opacity = '';
}
}
async function handleBulkAddTag(e) {
e.preventDefault();
const name = dom.tagInput.value.trim();
+83
View File
@@ -0,0 +1,83 @@
// /app/static/js/gallery-series-editor.js
// Series editor wired into the bulk-editor panel on series-filtered gallery views.
// No-ops when the page has no series context.
document.addEventListener('DOMContentLoaded', () => {
const seriesInfo = window.galleryState && window.galleryState.seriesInfo;
if (!seriesInfo) return;
const startPageInput = document.getElementById('startPageInput');
const bulkAddToSeriesBtn = document.getElementById('bulkAddToSeriesBtn');
const feedbackEl = document.getElementById('seriesEditorFeedback');
if (!startPageInput || !bulkAddToSeriesBtn || !feedbackEl) return;
const seriesTagId = seriesInfo.tag_id;
function updateAddButtonState() {
const selectedCount = window.selectedImages ? window.selectedImages.size : 0;
bulkAddToSeriesBtn.disabled = selectedCount === 0;
bulkAddToSeriesBtn.textContent = selectedCount > 0
? `Add ${selectedCount} to Series`
: 'Add to Series';
}
document.addEventListener('selectionChanged', updateAddButtonState);
bulkAddToSeriesBtn.addEventListener('click', async () => {
if (!window.selectedImages || window.selectedImages.size === 0) return;
const imageIds = window.selectionOrder ? [...window.selectionOrder] : Array.from(window.selectedImages);
const startPage = parseInt(startPageInput.value) || 1;
bulkAddToSeriesBtn.disabled = true;
bulkAddToSeriesBtn.textContent = 'Adding...';
feedbackEl.innerHTML = '';
try {
const r = await fetch(`/api/series/${seriesTagId}/pages/bulk-add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_ids: imageIds, start_page: startPage })
});
const data = await r.json();
if (data.ok) {
let msg = `Added ${data.added.length} image(s) to series.`;
if (data.skipped.length > 0) {
msg += ` Skipped ${data.skipped.length}.`;
}
feedbackEl.innerHTML = `<div class="feedback-success">${msg}</div>`;
if (data.added.length > 0) {
const lastPage = data.added[data.added.length - 1].page_number;
startPageInput.value = lastPage + 1;
}
if (window.clearSelection) {
window.clearSelection();
}
const pageCountEl = document.getElementById('seriesPageCount');
if (pageCountEl) {
const currentCount = parseInt(pageCountEl.textContent) || 0;
pageCountEl.textContent = `${currentCount + data.added.length} pages`;
}
// "Read Series" button only renders when page_count > 0. If this was the first
// batch of pages added, reload so the button appears.
const readBtn = document.querySelector('.series-header-actions .primary-btn');
if (!readBtn && data.added.length > 0) {
location.reload();
}
} else {
feedbackEl.innerHTML = `<div class="feedback-error">${data.error}</div>`;
}
} catch (e) {
feedbackEl.innerHTML = `<div class="feedback-error">Error: ${e.message}</div>`;
} finally {
updateAddButtonState();
}
});
updateAddButtonState();
});
+37 -4
View File
@@ -20,6 +20,15 @@
let columnHeights = [];
let isLoading = false;
let currentColumnCount = 0;
const seenImageIds = new Set();
function buildRandomUrl(count) {
const params = new URLSearchParams({ count: String(count) });
if (seenImageIds.size > 0) {
params.set('exclude', [...seenImageIds].join(','));
}
return `/api/random-images?${params.toString()}`;
}
// Initialize
function init() {
@@ -149,6 +158,13 @@
const imgEl = document.createElement('img');
imgEl.src = img.thumb_url;
imgEl.alt = img.filename;
// Reserve the correct layout slot before the image loads so the masonry
// doesn't reflow when thumbnails arrive. Width/height populate on import
// and may be missing on older records — fall back to natural sizing.
if (img.width && img.height) {
imgEl.width = img.width;
imgEl.height = img.height;
}
if (useLazyLoading) {
imgEl.loading = 'lazy';
}
@@ -173,7 +189,10 @@
}
function distributeImages(images, useLazyLoading = false, animate = false) {
const colWidth = columns[0] ? columns[0].clientWidth : 0;
images.forEach((img, i) => {
seenImageIds.add(img.id);
const colIndex = getShortestColumnIndex();
const item = createImageElement(img, useLazyLoading);
@@ -186,7 +205,13 @@
}
columns[colIndex].appendChild(item);
columnHeights[colIndex] += 300;
// Predict rendered height from the intrinsic aspect ratio so masonry
// packing is accurate before the thumb loads. Fall back to a flat guess
// when W/H are missing.
const predictedHeight = (colWidth && img.width && img.height)
? colWidth * (img.height / img.width)
: 300;
columnHeights[colIndex] += predictedHeight;
const imgEl = item.querySelector('img');
imgEl.onload = () => { updateColumnHeights(); };
});
@@ -228,10 +253,18 @@
container.classList.add('loading');
try {
const response = await fetch(`/api/random-images?count=20`);
let response = await fetch(buildRandomUrl(20));
if (!response.ok) throw new Error('Failed to fetch');
let data = await response.json();
const data = await response.json();
// If the collection is exhausted for this session, reset and retry once
// so shuffle keeps feeling fresh on a "second lap".
if (data.images.length === 0 && seenImageIds.size > 0) {
seenImageIds.clear();
response = await fetch(buildRandomUrl(20));
if (!response.ok) throw new Error('Failed to fetch');
data = await response.json();
}
// Clear everything
columns.forEach(col => col.innerHTML = '');
@@ -254,7 +287,7 @@
isLoading = true;
try {
const response = await fetch(`/api/random-images?count=${LOAD_BATCH_SIZE}`);
const response = await fetch(buildRandomUrl(LOAD_BATCH_SIZE));
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
+61 -3
View File
@@ -630,9 +630,30 @@ document.addEventListener('DOMContentLoaded', () => {
function positionAutocomplete() {
if (!tagAutocomplete || !tagInput) return;
const rect = tagInput.getBoundingClientRect();
tagAutocomplete.style.top = `${rect.bottom + 4}px`;
const gap = 4;
tagAutocomplete.style.left = `${rect.left}px`;
tagAutocomplete.style.width = `${rect.width}px`;
// Measure the dropdown height. Requires the element to be visible
// (callers add the `active` class before invoking this function).
const dropdownHeight = tagAutocomplete.offsetHeight;
// If the Suggestions section sits below the input and the default
// downward dropdown would overlap it, flip the dropdown above the
// input so the user can still see their suggestion chips.
let flipUp = false;
if (suggestionsSection && suggestionsSection.offsetParent !== null) {
const suggestionsRect = suggestionsSection.getBoundingClientRect();
const wouldOverlap = (rect.bottom + gap + dropdownHeight) > suggestionsRect.top;
const fitsAbove = (rect.top - gap - dropdownHeight) >= 0;
if (wouldOverlap && fitsAbove) flipUp = true;
}
if (flipUp) {
tagAutocomplete.style.top = `${rect.top - gap - dropdownHeight}px`;
} else {
tagAutocomplete.style.top = `${rect.bottom + gap}px`;
}
}
function renderAutocomplete(tags) {
@@ -642,8 +663,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (tags.length === 0) {
tagAutocomplete.innerHTML = '<div class="tag-autocomplete-empty">No matching tags</div>';
positionAutocomplete();
tagAutocomplete.classList.add('active');
positionAutocomplete();
return;
}
@@ -655,8 +676,8 @@ document.addEventListener('DOMContentLoaded', () => {
<span class="tag-kind">${t.kind || 'user'}</span>
</div>`;
}).join('');
positionAutocomplete();
tagAutocomplete.classList.add('active');
positionAutocomplete();
}
function selectAutocompleteItem(index) {
@@ -810,6 +831,12 @@ document.addEventListener('DOMContentLoaded', () => {
function openModal(index) {
modal.classList.add('active');
updateImage(index);
// Auto-focus the tag input on desktop so users can start typing immediately.
// Skipped on mobile — the on-screen keyboard would shove the image out of
// view and focus feels unexpected on touch.
if (tagInput && window.innerWidth >= 768) {
tagInput.focus();
}
history.replaceState({ modalIndex: index }, '', window.location.href);
}
@@ -978,6 +1005,37 @@ document.addEventListener('DOMContentLoaded', () => {
toggleZoom();
});
// Touch-swipe prev/next. Skipped when zoomed (drag-to-pan wins) or on
// multi-touch (pinch). Setting dragMoved suppresses the synthesized
// click that would otherwise toggle zoom after navigation.
const SWIPE_MIN_DISTANCE = 50;
const SWIPE_DOMINANCE = 1.5;
let touchStartX = null;
let touchStartY = null;
modalImageWrapper.addEventListener('touchstart', (e) => {
if (isZoomed || e.touches.length !== 1) {
touchStartX = null;
return;
}
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
}, { passive: true });
modalImageWrapper.addEventListener('touchend', (e) => {
if (touchStartX === null) return;
const touch = e.changedTouches[0];
const dx = touch.clientX - touchStartX;
const dy = touch.clientY - touchStartY;
touchStartX = null;
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDx < SWIPE_MIN_DISTANCE) return;
if (absDx < absDy * SWIPE_DOMINANCE) return;
dragMoved = true;
if (dx > 0) showPrev(); else showNext();
});
modalClose?.addEventListener('click', closeModal);
modalPrev?.addEventListener('click', showPrev);
modalNext?.addEventListener('click', showNext);
+6 -85
View File
@@ -144,6 +144,11 @@
<p class="form-help">Common tags across selected images:</p>
<div id="commonTagsList" class="tags"></div>
</div>
<div class="bulk-editor-section">
<h4>Suggestions</h4>
<p class="form-help" id="bulkSuggestionsHint">Select 2+ images to see consensus suggestions.</p>
<div id="bulkSuggestionsList" class="suggestions-list"></div>
</div>
{% if series_info %}
<div class="bulk-editor-section bulk-editor-series">
<h4>📚 Add to Series</h4>
@@ -179,91 +184,7 @@
<script src="{{ url_for('static', filename='js/gallery-infinite.js') }}"></script>
<script src="{{ url_for('static', filename='js/view-modal.js') }}"></script>
<script src="{{ url_for('static', filename='js/bulk-select.js') }}"></script>
{% if series_info %}
<script>
// Series Editor functionality (integrated into bulk editor panel)
document.addEventListener('DOMContentLoaded', () => {
const startPageInput = document.getElementById('startPageInput');
const bulkAddToSeriesBtn = document.getElementById('bulkAddToSeriesBtn');
const feedbackEl = document.getElementById('seriesEditorFeedback');
const seriesTagId = {{ series_info.tag_id }};
// Update button state based on selection
function updateAddButtonState() {
const selectedCount = window.selectedImages ? window.selectedImages.size : 0;
bulkAddToSeriesBtn.disabled = selectedCount === 0;
bulkAddToSeriesBtn.textContent = selectedCount > 0
? `Add ${selectedCount} to Series`
: 'Add to Series';
}
// Listen for selection changes
document.addEventListener('selectionChanged', updateAddButtonState);
// Add selected images to series
bulkAddToSeriesBtn.addEventListener('click', async () => {
if (!window.selectedImages || window.selectedImages.size === 0) return;
// Use selectionOrder (click order) for page ordering
const imageIds = window.selectionOrder ? [...window.selectionOrder] : Array.from(window.selectedImages);
const startPage = parseInt(startPageInput.value) || 1;
bulkAddToSeriesBtn.disabled = true;
bulkAddToSeriesBtn.textContent = 'Adding...';
feedbackEl.innerHTML = '';
try {
const r = await fetch(`/api/series/${seriesTagId}/pages/bulk-add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_ids: imageIds, start_page: startPage })
});
const data = await r.json();
if (data.ok) {
let msg = `Added ${data.added.length} image(s) to series.`;
if (data.skipped.length > 0) {
msg += ` Skipped ${data.skipped.length}.`;
}
feedbackEl.innerHTML = `<div class="feedback-success">${msg}</div>`;
// Update the starting page input to the next available page
if (data.added.length > 0) {
const lastPage = data.added[data.added.length - 1].page_number;
startPageInput.value = lastPage + 1;
}
// Clear selection
if (window.clearSelection) {
window.clearSelection();
}
// Update page count in header
const pageCountEl = document.getElementById('seriesPageCount');
if (pageCountEl) {
const currentCount = parseInt(pageCountEl.textContent) || 0;
pageCountEl.textContent = `${currentCount + data.added.length} pages`;
}
// Show "Read Series" button if it was hidden (first pages added)
const readBtn = document.querySelector('.series-header-actions .primary-btn');
if (!readBtn && data.added.length > 0) {
location.reload(); // Simplest way to update the UI
}
} else {
feedbackEl.innerHTML = `<div class="feedback-error">${data.error}</div>`;
}
} catch (e) {
feedbackEl.innerHTML = `<div class="feedback-error">Error: ${e.message}</div>`;
} finally {
updateAddButtonState();
}
});
// Initial state
updateAddButtonState();
});
</script>
<script src="{{ url_for('static', filename='js/gallery-series-editor.js') }}"></script>
{% endif %}
{% endblock %}
+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;
}
+2
View File
@@ -42,6 +42,8 @@
"thumb_url": "{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '')) }}",
"full_url": "{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '')) }}",
"is_video": {{ 'true' if image.filename.lower().endswith(('.mp4', '.mov')) else 'false' }},
"width": {{ image.width if image.width else 'null' }},
"height": {{ image.height if image.height else 'null' }},
"tags": [{% for t in image.tags %}{"name": "{{ t.name | e }}", "kind": "{{ t.kind }}"}{% if not loop.last %}, {% endif %}{% endfor %}]
}{% if not loop.last %},{% endif %}
{% endfor %}]