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
+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);