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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user