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