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