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:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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