This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
imagerepo/app/static/js/view-modal.js
T
bvandeusen a510665a17 feat(suggestions): user-managed blocklist for noisy auto-tags
Adds a tag_suggestion_blocklist table + service-layer filter so the
user can permanently suppress specific canonical tag names from the
suggestion stream (WD14 anatomy/composition tags like '1girl',
'breasts', 'nipples' that aren't useful for the discoverability use
case this app targets).

Data model
  - migration k26042201: tag_suggestion_blocklist(name TEXT PK, created_at)
  - model TagSuggestionBlocklistEntry

Service
  - tag_suggestions._blocklisted_names() snapshots the current set
  - get_suggestions filters merged results before grouping, so both
    WD14- and embedding-sourced suggestions respect the blocklist
  - get_bulk_suggestions inherits the filter via its per-image call
    to get_suggestions

API
  - GET  /api/suggestions/blocklist           -> {ok, names}
  - POST /api/suggestions/blocklist           -> add one
  - POST /api/suggestions/blocklist/delete    -> remove one
  - POST /api/suggestions/blocklist/bulk      -> replace the whole list
    (backs the settings textarea save button)

UI
  - modal suggestion chip gets a third action button (⊘) alongside
    accept (✓) / reject (✕). Clicking it adds the name to the
    blocklist, logs a rejection for ML feedback on this image, and
    sweeps every chip on the page carrying that same name.
  - Settings -> Maintenance -> Suggestion blocklist section with a
    monospaced textarea (one name per line) + Save. Loads current
    entries on mount.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 08:41:44 -04:00

1333 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// /app/static/js/view-modal.js
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('imageModal');
const modalPrev = document.getElementById('modalPrev');
const modalNext = document.getElementById('modalNext');
const modalClose = document.getElementById('modalClose');
const modalImageWrapper = document.querySelector('.modal-image-wrapper');
// NEW: tag editor elements
const tagEditor = document.getElementById('modalTagEditor');
const tagList = document.getElementById('modalTagList');
const tagForm = document.getElementById('modalTagForm');
const tagInput = tagForm ? tagForm.querySelector('input[name="name"]') : null;
const tagAutocomplete = document.getElementById('tagAutocomplete');
const tagActionFeedback = document.getElementById('tagActionFeedback');
const fandomPickerWrapper = document.getElementById('fandomPickerWrapper');
const fandomPickerInput = document.getElementById('fandomPickerInput');
const fandomPickerAutocomplete = document.getElementById('fandomPickerAutocomplete');
const fandomPickerClear = document.getElementById('fandomPickerClear');
let fandomPickerDebounce = null;
const provenanceSection = document.getElementById('modalProvenanceSection');
const provenanceList = document.getElementById('modalProvenanceList');
const suggestionsSection = document.getElementById('modalSuggestionsSection');
const suggestionsList = document.getElementById('modalSuggestionsList');
const suggestionsRefresh = document.getElementById('suggestionsRefreshBtn');
// Series info elements (when image IS in a series)
const seriesInfoEl = document.getElementById('modalSeriesInfo');
const seriesNameEl = document.getElementById('modalSeriesName');
const pageNumberInput = document.getElementById('modalPageNumber');
const updatePageBtn = document.getElementById('updatePageBtn');
const pageUpdateStatus = document.getElementById('pageUpdateStatus');
let currentSeriesPageId = null;
// Add to series elements (when image is NOT in a series)
const addToSeriesEl = document.getElementById('modalAddToSeries');
const seriesSelect = document.getElementById('seriesSelect');
const addSeriesPageNumber = document.getElementById('addSeriesPageNumber');
const addToSeriesBtn = document.getElementById('addToSeriesBtn');
const addToSeriesStatus = document.getElementById('addToSeriesStatus');
const addToSeriesToggle = document.getElementById('addToSeriesToggle');
const addToSeriesBody = document.getElementById('addToSeriesBody');
let seriesTagsLoaded = false;
// In-context feedback: show a brief message next to an element, auto-fades
function showFeedback(el, message, isError = false) {
if (!el) return;
el.textContent = message;
el.style.color = isError ? 'var(--flash-danger)' : 'var(--flash-success)';
el.classList.add('visible');
clearTimeout(el._feedbackTimeout);
el._feedbackTimeout = setTimeout(() => {
el.classList.remove('visible');
}, 1500);
}
// Autocomplete state
let autocompleteItems = [];
let autocompleteSelectedIndex = -1;
let autocompleteDebounce = null;
let images = Array.from(document.querySelectorAll('.img-clickable'));
let currentIndex = -1;
let isZoomed = false;
let isDragging = false;
let startX, startY, scrollLeft, scrollTop;
let dragStartX = 0;
let dragStartY = 0;
let dragMoved = false;
// Legacy pagination inputs (for non-infinite-scroll pages)
const hasNextPageInput = document.getElementById('hasNextPage');
const nextPageUrlInput = document.getElementById('nextPageUrl');
const hasPrevPageInput = document.getElementById('hasPrevPage');
const prevPageUrlInput = document.getElementById('prevPageUrl');
// Check if we're in infinite scroll mode
// Infinite scroll mode is when galleryState exists (set by gallery.html template)
const isInfiniteScrollMode = typeof window.galleryState !== 'undefined';
// ---------------------------
// Tag editor helpers
// ---------------------------
// Use shared TagUtils module
const { getTagIcon, getTagDisplayName, getTagDisplayNameForOverlay, escapeHtml, createTagChipHtml } = window.TagUtils || {};
function setEditorImageId(id) {
if (tagEditor) tagEditor.dataset.imageId = id || '';
}
function getEditorImageId() {
return tagEditor ? tagEditor.dataset.imageId : '';
}
function getProvenanceDisplayName(name, kind) {
// Artist tag names are "artist:name" — strip the prefix.
if (kind === 'artist' && name.startsWith('artist:')) return name.slice(7);
// Post tag names are "post:platform:artist:id" — strip "post:" only; the rest
// is a placeholder replaced by PostMetadata.title or post_id once it loads.
if (kind === 'post' && name.startsWith('post:')) return name.slice(5);
return name;
}
function renderTags(tags) {
if (!tagList) return;
tagList.innerHTML = '';
(tags || []).forEach(t => {
const chip = document.createElement('span');
chip.className = 'tag-chip';
const icon = getTagIcon(t.kind);
// Use display_name so character pills show the "(Fandom)" suffix.
const label = t.display_name || t.name;
chip.innerHTML = `${icon} ${label} <button class="x" data-name="${t.name}" title="Remove">×</button>`;
tagList.appendChild(chip);
});
}
const SUGGESTION_CATEGORY_ORDER = ['character', 'copyright', 'general'];
const SUGGESTION_CATEGORY_LABEL = {
character: 'Characters',
copyright: 'Fandoms',
general: 'General',
};
function clearSuggestions() {
if (suggestionsList) suggestionsList.innerHTML = '';
if (suggestionsSection) suggestionsSection.style.display = 'none';
}
function buildSuggestionChip(suggestion, imageId) {
const chip = document.createElement('div');
chip.className = 'suggestion-chip';
chip.dataset.tagName = suggestion.name;
chip.dataset.source = suggestion.source;
chip.dataset.category = suggestion.category;
chip.dataset.confidence = String(suggestion.confidence);
chip.title = `${suggestion.name} (${suggestion.source})`;
const pct = Math.round(suggestion.confidence * 100);
chip.innerHTML = `
<button type="button" class="sugg-btn sugg-accept" aria-label="Accept">✓</button>
<span class="sugg-body">
<span class="sugg-label">${escapeHtml(suggestion.name)}</span>
<span class="sugg-confidence">${pct}%</span>
</span>
<button type="button" class="sugg-btn sugg-reject" aria-label="Reject">✕</button>
<button type="button" class="sugg-btn sugg-block" aria-label="Never suggest this tag" title="Never suggest '${escapeHtml(suggestion.name)}' again">⊘</button>
`;
const acceptBtn = chip.querySelector('.sugg-accept');
const rejectBtn = chip.querySelector('.sugg-reject');
const blockBtn = chip.querySelector('.sugg-block');
if (acceptBtn) {
acceptBtn.addEventListener('click', () => acceptSuggestion(chip, imageId));
}
if (rejectBtn) {
rejectBtn.addEventListener('click', () => rejectSuggestion(chip, imageId));
}
if (blockBtn) {
blockBtn.addEventListener('click', () => blocklistSuggestion(chip, imageId));
}
return chip;
}
async function blocklistSuggestion(chip, imageId) {
if (chip.dataset.disabled === 'true') return;
chip.dataset.disabled = 'true';
chip.style.pointerEvents = 'none';
const name = chip.dataset.tagName;
try {
// Add to the user's blocklist.
const r = await fetch('/api/suggestions/blocklist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
const j = await r.json();
if (!j.ok) {
chip.dataset.disabled = 'false';
chip.style.pointerEvents = '';
if (tagActionFeedback) tagActionFeedback.textContent = `Couldn't blocklist: ${j.error || 'error'}`;
return;
}
// Also log a rejection for this image so the ML feedback signal is
// captured — the user rejected this specific suggestion too.
try {
await fetch(`/image/${imageId}/suggestions/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tag_name: name,
source: chip.dataset.source || '',
confidence: Number(chip.dataset.confidence) || 0,
}),
});
} catch {
// best-effort — blocklist succeeded is what matters
}
if (tagActionFeedback) tagActionFeedback.textContent = `Blocklisted '${name}'`;
// Remove every chip on the page with this name so the user doesn't have to
// dismiss the same suggestion once per category.
document.querySelectorAll('.suggestion-chip').forEach(el => {
if (el.dataset.tagName === name) animateChipOut(el);
});
} catch {
chip.dataset.disabled = 'false';
chip.style.pointerEvents = '';
}
}
function renderSuggestions(grouped, imageId) {
if (!suggestionsSection || !suggestionsList) return;
suggestionsList.innerHTML = '';
let total = 0;
for (const cat of SUGGESTION_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 = SUGGESTION_CATEGORY_LABEL[cat] || cat;
group.appendChild(label);
const chips = document.createElement('div');
chips.className = 'suggestions-chips';
for (const item of items) {
chips.appendChild(buildSuggestionChip(item, imageId));
}
group.appendChild(chips);
suggestionsList.appendChild(group);
}
suggestionsSection.style.display = total > 0 ? '' : 'none';
}
async function loadSuggestions(imageId) {
if (!imageId || !suggestionsSection) { clearSuggestions(); return; }
try {
const r = await fetch(`/image/${imageId}/suggestions`);
const j = await r.json();
if (j.ok) renderSuggestions(j.suggestions || {}, imageId);
else clearSuggestions();
} catch {
clearSuggestions();
}
}
function animateChipOut(chip) {
chip.classList.add('leaving');
setTimeout(() => { chip.remove(); }, 200);
}
async function acceptSuggestion(chip, imageId) {
if (chip.dataset.disabled === 'true') return;
chip.dataset.disabled = 'true';
chip.style.pointerEvents = 'none';
const name = chip.dataset.tagName || '';
const category = chip.dataset.category || '';
const source = chip.dataset.source || '';
const confidence = Number(chip.dataset.confidence) || 0;
try {
const r = await fetch(`/image/${imageId}/suggestions/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, category, source, confidence }),
});
if (r.status === 409) {
const body = await r.json();
if (body && body.error === 'ambiguous' && Array.isArray(body.candidates)) {
renderAmbiguousPicker(chip, body.candidates, { name, category, source, confidence, imageId });
return; // chip stays disabled; picker handles next step
}
}
const j = await r.json();
if (!j.ok) {
chip.dataset.disabled = 'false';
chip.style.pointerEvents = '';
if (tagActionFeedback) {
tagActionFeedback.textContent = `Couldn't accept: ${j.error || 'error'}`;
}
return;
}
if (typeof loadTags === 'function') await loadTags(imageId);
if (tagActionFeedback) tagActionFeedback.textContent = `Added ${j.tag.display_name || j.tag.name}`;
if (typeof window.refreshItemTags === 'function') window.refreshItemTags(imageId);
animateChipOut(chip);
} catch {
chip.dataset.disabled = 'false';
chip.style.pointerEvents = '';
}
}
function renderAmbiguousPicker(chip, candidates, ctx) {
const picker = document.createElement('div');
picker.className = 'suggestion-ambiguous-picker';
const label = document.createElement('div');
label.className = 'ambiguous-label';
label.textContent = 'Which one?';
picker.appendChild(label);
for (const c of candidates) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ambiguous-candidate';
btn.dataset.tagId = String(c.id);
btn.textContent = c.display_name;
btn.addEventListener('click', async () => {
picker.querySelectorAll('button').forEach(b => { b.disabled = true; });
try {
const r = await fetch(`/image/${ctx.imageId}/suggestions/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: ctx.name,
category: ctx.category,
source: ctx.source,
confidence: ctx.confidence,
tag_id: c.id,
}),
});
const j = await r.json();
if (!j.ok) {
picker.querySelectorAll('button').forEach(b => { b.disabled = false; });
if (tagActionFeedback) {
tagActionFeedback.textContent = `Couldn't accept: ${j.error || 'error'}`;
}
return;
}
if (typeof loadTags === 'function') await loadTags(ctx.imageId);
if (tagActionFeedback) tagActionFeedback.textContent = `Added ${j.tag.display_name || j.tag.name}`;
if (typeof window.refreshItemTags === 'function') window.refreshItemTags(ctx.imageId);
animateChipOut(chip);
} catch {
picker.querySelectorAll('button').forEach(b => { b.disabled = false; });
}
});
picker.appendChild(btn);
}
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'ambiguous-cancel';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => {
picker.remove();
chip.dataset.disabled = 'false';
chip.style.pointerEvents = '';
});
picker.appendChild(cancelBtn);
chip.appendChild(picker);
}
async function rejectSuggestion(chip, imageId) {
chip.style.pointerEvents = 'none';
try {
await fetch(`/image/${imageId}/suggestions/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tag_name: chip.dataset.tagName,
source: chip.dataset.source,
confidence: Number(chip.dataset.confidence) || 0,
}),
});
animateChipOut(chip);
if (tagActionFeedback) tagActionFeedback.textContent = `Rejected ${chip.dataset.tagName}`;
} catch {
chip.style.pointerEvents = '';
}
}
function renderProvenance(tags) {
if (!provenanceSection || !provenanceList) return;
provenanceList.innerHTML = '';
if (!tags || tags.length === 0) {
provenanceSection.style.display = 'none';
return;
}
tags.forEach(t => {
const chip = document.createElement('a');
chip.className = 'provenance-chip';
chip.href = `/gallery?tag=${encodeURIComponent(t.name)}`;
chip.title = t.name;
chip.dataset.tagName = t.name;
chip.dataset.kind = t.kind;
const icon = getTagIcon(t.kind);
const placeholder = escapeHtml(getProvenanceDisplayName(t.name, t.kind));
chip.innerHTML = `${icon} <span class="provenance-label">${placeholder}</span> <span class="provenance-arrow">↗</span>`;
provenanceList.appendChild(chip);
if (t.kind === 'post') upgradeProvenanceChip(chip, t.name);
});
provenanceSection.style.display = '';
}
async function upgradeProvenanceChip(chip, tagName) {
try {
const r = await fetch(`/api/post-metadata/by-tag-name/${encodeURIComponent(tagName)}`);
const j = await r.json();
if (!j.ok || !j.metadata) return;
const pm = j.metadata;
if (pm.platform) chip.dataset.platform = pm.platform;
const label = pm.title || pm.post_id;
if (label) {
const labelEl = chip.querySelector('.provenance-label');
if (labelEl) labelEl.textContent = label;
}
} catch {
// Fetch failed — chip keeps its placeholder label and neutral accent.
}
}
async function loadTags(imageId) {
if (!imageId) { renderTags([]); renderProvenance([]); return; }
try {
const r = await fetch(`/image/${imageId}/tags`);
const j = await r.json();
if (j.ok) {
const all = j.tags || [];
// Provenance: artist first, then post — non-editable, system-tagged origin info.
const provenanceTags = [
...all.filter(t => t.kind === 'artist'),
...all.filter(t => t.kind === 'post'),
];
// Editable list excludes provenance kinds and hidden kinds (source, archive).
const hiddenFromModal = new Set(['artist', 'post', 'source', 'archive']);
const editableTags = all.filter(t => !hiddenFromModal.has(t.kind));
renderTags(editableTags);
renderProvenance(provenanceTags);
loadSuggestions(imageId);
}
} catch {
// ignore
}
}
// ---------------------------
// Gallery item tag refresh (fallback if bulk-select.js not loaded)
// ---------------------------
async function refreshItemTagsFallback(imageId) {
// Only the gallery page renders tag overlays on thumbnails. Showcase thumbnails
// use a tag-count badge instead and must not have .tag-overlay forced onto them.
const item = document.querySelector(`.gallery-item[data-id="${imageId}"]`);
if (!item) return;
try {
const r = await fetch(`/image/${imageId}/tags`);
const data = await r.json();
if (!data.ok) return;
const visibleTags = (data.tags || []).filter(t => t.kind !== 'archive' && t.kind !== 'source');
let overlay = item.querySelector('.tag-overlay');
if (visibleTags.length === 0) {
if (overlay) overlay.remove();
return;
}
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'tag-overlay';
item.appendChild(overlay);
}
overlay.innerHTML = visibleTags.map(t => createTagChipHtml(t)).join('');
} catch (e) {
console.error('Failed to refresh tags for image', imageId, e);
}
}
// Register fallback if bulk-select.js hasn't registered it
if (!window.refreshItemTags) {
window.refreshItemTags = refreshItemTagsFallback;
}
// ---------------------------
// Series info helpers
// ---------------------------
function hideSeriesInfo() {
if (seriesInfoEl) seriesInfoEl.style.display = 'none';
currentSeriesPageId = null;
if (pageUpdateStatus) { pageUpdateStatus.textContent = ''; pageUpdateStatus.classList.remove('visible'); }
}
function hideAddToSeries() {
if (addToSeriesEl) addToSeriesEl.style.display = 'none';
if (addToSeriesStatus) { addToSeriesStatus.textContent = ''; addToSeriesStatus.classList.remove('visible'); }
// Reset toggle state
if (addToSeriesToggle) addToSeriesToggle.classList.remove('open');
if (addToSeriesBody) addToSeriesBody.style.display = 'none';
}
async function loadSeriesTags() {
if (!seriesSelect) return;
if (seriesTagsLoaded) return Promise.resolve();
try {
// Fetch all series tags using the kind filter
const r = await fetch('/api/tags/search?kind=series&limit=100');
const j = await r.json();
const seriesTags = j.tags || [];
// Clear existing options except the first placeholder
seriesSelect.innerHTML = '<option value="">Select series...</option>';
seriesTags.forEach(tag => {
const opt = document.createElement('option');
opt.value = tag.id;
opt.textContent = tag.display_name;
seriesSelect.appendChild(opt);
});
seriesTagsLoaded = true;
} catch {
// ignore
}
}
function showAddToSeries(seriesPage = null) {
if (!addToSeriesEl) return;
// Store pending pre-fill data for when the toggle is opened
addToSeriesEl._pendingSeriesPage = seriesPage || null;
if (addToSeriesStatus) { addToSeriesStatus.textContent = ''; addToSeriesStatus.classList.remove('visible'); }
// Show the outer container; inner body stays hidden until toggle click
addToSeriesEl.style.display = 'block';
}
async function handleAddToSeries() {
const imageId = getEditorImageId();
const seriesTagId = seriesSelect?.value;
const pageNumber = parseInt(addSeriesPageNumber?.value);
if (!imageId || !seriesTagId || isNaN(pageNumber) || pageNumber < 1) return;
const isUpdate = addToSeriesBtn?.textContent === 'Update';
if (addToSeriesBtn) addToSeriesBtn.disabled = true;
showFeedback(addToSeriesStatus, isUpdate ? 'Updating…' : 'Adding…');
try {
const r = await fetch(`/api/image/${imageId}/add-to-series`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ series_tag_id: parseInt(seriesTagId), page_number: pageNumber })
});
const j = await r.json();
if (j.ok) {
showFeedback(addToSeriesStatus, 'Saved');
// Fully reload series info to show updated management interface
await loadSeriesInfo(imageId);
} else {
showFeedback(addToSeriesStatus, j.error || 'Error', true);
if (addToSeriesBtn) addToSeriesBtn.disabled = false;
}
} catch (e) {
showFeedback(addToSeriesStatus, 'Error', true);
if (addToSeriesBtn) addToSeriesBtn.disabled = false;
}
}
function showSeriesInfo(seriesPage) {
if (!seriesInfoEl || !seriesPage) return;
currentSeriesPageId = seriesPage.id;
if (seriesNameEl) {
seriesNameEl.textContent = seriesPage.series_name;
seriesNameEl.href = `/gallery?tag_id=${encodeURIComponent(seriesPage.series_tag_id)}`;
}
// Display page number
if (pageNumberInput) {
pageNumberInput.value = seriesPage.page_number;
}
if (pageUpdateStatus) { pageUpdateStatus.textContent = ''; pageUpdateStatus.classList.remove('visible'); }
seriesInfoEl.style.display = 'block';
}
async function loadSeriesInfo(imageId) {
hideSeriesInfo();
hideAddToSeries();
if (!imageId) return;
try {
const r = await fetch(`/api/image/${imageId}/series-info`);
const j = await r.json();
if (j.ok && j.in_series) {
// Show series info (with link) and pre-fill the dropdown
showSeriesInfo(j.series_page);
showAddToSeries(j.series_page);
} else {
// Show add-to-series option when not in a series
// Auto-select if image has exactly one series tag
const seriesTags = j.series_tags || [];
if (seriesTags.length === 1) {
showAddToSeries({ series_tag_id: seriesTags[0].id, page_number: 1 });
} else {
showAddToSeries();
}
}
} catch {
// ignore
}
}
async function updatePageNumber() {
if (!currentSeriesPageId || !pageNumberInput) return;
const newPageNumber = parseInt(pageNumberInput.value);
if (isNaN(newPageNumber) || newPageNumber < 1) {
showFeedback(pageUpdateStatus, 'Invalid', true);
return;
}
if (updatePageBtn) updatePageBtn.disabled = true;
showFeedback(pageUpdateStatus, 'Saving…');
try {
const fd = new FormData();
fd.append('page_number', newPageNumber);
const r = await fetch(`/api/series/page/${currentSeriesPageId}/update`, {
method: 'POST',
body: fd
});
const j = await r.json();
if (j.ok) {
showFeedback(pageUpdateStatus, 'Saved');
} else {
showFeedback(pageUpdateStatus, j.error || 'Error', true);
}
} catch (e) {
showFeedback(pageUpdateStatus, 'Error', true);
} finally {
if (updatePageBtn) updatePageBtn.disabled = false;
}
}
// Wire up the save button
if (updatePageBtn) {
updatePageBtn.addEventListener('click', updatePageNumber);
}
// Allow Enter key in page number input to save
if (pageNumberInput) {
pageNumberInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
updatePageNumber();
}
});
}
// Enable/disable add button based on series selection
if (seriesSelect) {
seriesSelect.addEventListener('change', () => {
if (addToSeriesBtn) {
addToSeriesBtn.disabled = !seriesSelect.value;
// Update button text: "Update" if image is in a series, "Add" if not
if (seriesSelect.value && currentSeriesPageId) {
addToSeriesBtn.textContent = 'Update';
} else if (seriesSelect.value) {
addToSeriesBtn.textContent = 'Add';
}
}
});
}
// Add to series button click
if (addToSeriesBtn) {
addToSeriesBtn.addEventListener('click', handleAddToSeries);
}
// Allow Enter key in add-to-series page number input
if (addSeriesPageNumber) {
addSeriesPageNumber.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && seriesSelect?.value) {
e.preventDefault();
handleAddToSeries();
}
});
}
// Toggle "Add to Series" collapse
if (addToSeriesToggle) {
addToSeriesToggle.addEventListener('click', () => {
const isOpen = addToSeriesToggle.classList.toggle('open');
addToSeriesBody.style.display = isOpen ? 'block' : 'none';
// Load series options on first open, then apply any pending pre-fill
if (isOpen) {
const seriesPage = addToSeriesEl._pendingSeriesPage;
loadSeriesTags().then(() => {
if (seriesPage && seriesSelect) {
seriesSelect.value = seriesPage.series_tag_id;
if (addSeriesPageNumber) addSeriesPageNumber.value = seriesPage.page_number;
if (addToSeriesBtn) {
addToSeriesBtn.disabled = false;
addToSeriesBtn.textContent = 'Update';
}
} else {
if (seriesSelect) seriesSelect.value = '';
if (addSeriesPageNumber) addSeriesPageNumber.value = '1';
if (addToSeriesBtn) {
addToSeriesBtn.disabled = true;
addToSeriesBtn.textContent = 'Add';
}
}
});
}
});
}
async function addTag(imageId, name, { tagId, fandomId, fandomName } = {}) {
const fd = new FormData();
if (tagId) {
// Autocomplete-picker path: server attaches this exact tag, skipping
// parse_kind_prefix so a same-name character/user ambiguity is
// resolved by the row the user clicked.
fd.append('tag_id', String(tagId));
} else {
fd.append('name', name);
if (fandomId) {
fd.append('fandom_id', String(fandomId));
} else if (fandomName) {
fd.append('fandom_name', fandomName);
}
}
const r = await fetch(`/image/${imageId}/tags/add`, { method: 'POST', body: fd });
const j = await r.json();
if (j.ok) {
await loadTags(imageId);
showFeedback(tagActionFeedback, 'Tag added');
return true;
}
return false;
}
async function removeTag(imageId, name) {
const fd = new FormData();
fd.append('name', name);
const r = await fetch(`/image/${imageId}/tags/remove`, { method: 'POST', body: fd });
const j = await r.json();
if (j.ok) {
await loadTags(imageId);
showFeedback(tagActionFeedback, 'Tag removed');
return true;
}
return false;
}
// ---------------------------
// Fandom picker helpers
// ---------------------------
function clearFandomAutocomplete() {
if (fandomPickerAutocomplete) fandomPickerAutocomplete.innerHTML = '';
}
function clearFandomPicker() {
if (!fandomPickerInput) return;
fandomPickerInput.value = '';
delete fandomPickerInput.dataset.fandomId;
clearFandomAutocomplete();
fandomPickerWrapper?.classList.remove('visible');
fandomPickerWrapper?.setAttribute('aria-hidden', 'true');
}
function renderFandomAutocomplete(rows) {
if (!fandomPickerAutocomplete) return;
if (!rows || rows.length === 0) {
fandomPickerAutocomplete.innerHTML = '';
return;
}
fandomPickerAutocomplete.innerHTML = rows.map(r => {
const name = String(r.name || '').replace(/"/g, '&quot;');
const displayName = String(r.display_name || r.name || '').replace(/</g, '&lt;');
return `<div class="tag-autocomplete-item" data-fandom-id="${r.id}" data-fandom-name="${name}">${displayName}</div>`;
}).join('');
}
// ---------------------------
// Autocomplete helpers
// ---------------------------
// The autocomplete area is an always-reserved block below the input
// (see .tag-autocomplete in style.css), so there's nothing to show/hide —
// the area is either populated with matches, the empty-state message, or a
// muted placeholder when there's no active query.
function clearAutocomplete(placeholder = '') {
if (tagAutocomplete) {
tagAutocomplete.innerHTML = placeholder
? `<div class="tag-autocomplete-empty">${escapeHtml(placeholder)}</div>`
: '';
}
autocompleteItems = [];
autocompleteSelectedIndex = -1;
}
function renderAutocomplete(tags) {
if (!tagAutocomplete) return;
autocompleteItems = tags;
autocompleteSelectedIndex = -1;
if (tags.length === 0) {
tagAutocomplete.innerHTML = '<div class="tag-autocomplete-empty">No matching tags</div>';
return;
}
tagAutocomplete.innerHTML = tags.map((t, i) => {
const icon = getTagIcon(t.kind);
// Use display_name from the server so character+fandom rows show
// "Ruby Rose (RWBY)" — lets the user disambiguate same-name characters.
const label = t.display_name || t.name;
return `<div class="tag-autocomplete-item" data-index="${i}" data-tag-id="${t.id}" data-name="${t.name}" data-kind="${t.kind || ''}">
<span>${icon} ${label}</span>
<span class="tag-kind">${t.kind || 'user'}</span>
</div>`;
}).join('');
}
function selectAutocompleteItem(index) {
const items = tagAutocomplete?.querySelectorAll('.tag-autocomplete-item');
if (!items) return;
items.forEach((el, i) => {
el.classList.toggle('selected', i === index);
});
autocompleteSelectedIndex = index;
}
async function fetchAutocomplete(query) {
try {
// Exclude system-managed tag kinds from autocomplete (archive, post, source)
const excludeKinds = 'archive,post,source';
const url = query
? `/api/tags/search?q=${encodeURIComponent(query)}&limit=20&exclude_kind=${excludeKinds}`
: `/api/tags/search?limit=20&exclude_kind=${excludeKinds}`;
const r = await fetch(url);
const j = await r.json();
renderAutocomplete(j.tags || []);
} catch {
clearAutocomplete();
}
}
// Shared function to submit a tag (avoids page refresh issues with form dispatch).
// `pickedTagId` is set when the user clicks an autocomplete row or presses
// Enter on a highlighted row — in that case, skip parsing and attach that
// exact tag so same-name character/user ambiguity is resolved by the pick.
async function submitTag(pickedTagId = null) {
const id = getEditorImageId();
const name = (tagInput?.value || '').trim();
if (!id || (!name && !pickedTagId)) return;
const fandomId = fandomPickerInput?.dataset?.fandomId;
const fandomName = (fandomPickerInput?.value || '').trim();
const ok = await addTag(id, name, {
tagId: pickedTagId || null,
fandomId: fandomId ? Number(fandomId) : null,
fandomName: fandomName || null,
});
if (tagInput) tagInput.value = '';
clearFandomPicker();
// Reset the reserved area to the top-tags view so it's ready for the next add.
fetchAutocomplete('');
}
if (tagInput) {
// Refresh top tags on focus so the area reflects the latest input value.
tagInput.addEventListener('focus', () => {
fetchAutocomplete(tagInput.value.trim());
});
// Search as user types + toggle the fandom picker when the shortcut is 'character:'.
tagInput.addEventListener('input', () => {
const lower = (tagInput.value || '').toLowerCase();
const isCharacter = lower.startsWith('character:');
if (fandomPickerWrapper) {
fandomPickerWrapper.classList.toggle('visible', isCharacter);
fandomPickerWrapper.setAttribute('aria-hidden', String(!isCharacter));
}
if (!isCharacter) {
// Typing anything else cancels a previously-staged fandom id/name.
if (fandomPickerInput) {
fandomPickerInput.value = '';
delete fandomPickerInput.dataset.fandomId;
clearFandomAutocomplete();
}
}
clearTimeout(autocompleteDebounce);
autocompleteDebounce = setTimeout(() => {
fetchAutocomplete(tagInput.value.trim());
}, 150);
});
// Keyboard navigation — active whenever the reserved area has items.
tagInput.addEventListener('keydown', (e) => {
if (autocompleteItems.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
const nextIndex = Math.min(autocompleteSelectedIndex + 1, autocompleteItems.length - 1);
selectAutocompleteItem(nextIndex);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prevIndex = Math.max(autocompleteSelectedIndex - 1, 0);
selectAutocompleteItem(prevIndex);
} else if (e.key === 'Enter' && autocompleteSelectedIndex >= 0) {
e.preventDefault();
const selected = autocompleteItems[autocompleteSelectedIndex];
if (selected) {
tagInput.value = selected.display_name || selected.name;
submitTag(selected.id);
}
} else if (e.key === 'Escape') {
selectAutocompleteItem(-1);
}
});
}
// Click an autocomplete item to select and submit it. Use mousedown so the
// interaction still fires even though the input is losing focus.
if (tagAutocomplete) {
tagAutocomplete.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
const item = e.target.closest('.tag-autocomplete-item');
if (!item) return;
const name = item.dataset.name;
const tagId = item.dataset.tagId ? Number(item.dataset.tagId) : null;
if (tagInput && name) {
tagInput.value = name;
submitTag(tagId);
}
});
}
let fandomPickerSelectedIdx = -1;
function highlightFandomItem(idx) {
if (!fandomPickerAutocomplete) return;
const items = fandomPickerAutocomplete.querySelectorAll('.tag-autocomplete-item');
items.forEach((el, i) => el.classList.toggle('selected', i === idx));
fandomPickerSelectedIdx = idx;
}
function pickFandomRow(row) {
if (!row || !fandomPickerInput) return;
fandomPickerInput.value = row.dataset.fandomName;
fandomPickerInput.dataset.fandomId = row.dataset.fandomId;
clearFandomAutocomplete();
fandomPickerSelectedIdx = -1;
}
if (fandomPickerInput) {
fandomPickerInput.addEventListener('input', () => {
clearTimeout(fandomPickerDebounce);
const term = fandomPickerInput.value.trim();
// Any typing invalidates a previously-selected fandom id + highlight.
delete fandomPickerInput.dataset.fandomId;
fandomPickerSelectedIdx = -1;
if (!term) {
clearFandomAutocomplete();
return;
}
fandomPickerDebounce = setTimeout(() => {
fetch(`/api/tags/search?kind=fandom&q=${encodeURIComponent(term)}&limit=8`)
.then(r => r.json())
.then(body => renderFandomAutocomplete(body.tags || []))
.catch(() => clearFandomAutocomplete());
}, 150);
});
// Keyboard parity with the main tagInput: ArrowDown/Up to navigate rows,
// Enter to pick a highlighted row (or submit the whole form when no row
// is highlighted), Escape to clear the highlight.
fandomPickerInput.addEventListener('keydown', (e) => {
const rows = fandomPickerAutocomplete
? fandomPickerAutocomplete.querySelectorAll('.tag-autocomplete-item')
: [];
const count = rows.length;
if (e.key === 'ArrowDown' && count > 0) {
e.preventDefault();
highlightFandomItem(Math.min(fandomPickerSelectedIdx + 1, count - 1));
} else if (e.key === 'ArrowUp' && count > 0) {
e.preventDefault();
highlightFandomItem(Math.max(fandomPickerSelectedIdx - 1, 0));
} else if (e.key === 'Enter') {
if (fandomPickerSelectedIdx >= 0 && rows[fandomPickerSelectedIdx]) {
e.preventDefault();
pickFandomRow(rows[fandomPickerSelectedIdx]);
} else {
e.preventDefault();
submitTag();
}
} else if (e.key === 'Escape') {
highlightFandomItem(-1);
}
});
}
if (fandomPickerAutocomplete) {
fandomPickerAutocomplete.addEventListener('mousedown', (e) => {
const row = e.target.closest('[data-fandom-id]');
if (!row) return;
e.preventDefault(); // keep focus on the input
pickFandomRow(row);
});
}
if (fandomPickerClear) {
fandomPickerClear.addEventListener('click', clearFandomPicker);
}
if (tagForm) {
tagForm.addEventListener('submit', async (e) => {
e.preventDefault();
await submitTag();
});
}
if (tagList) {
tagList.addEventListener('click', async (e) => {
const btn = e.target.closest('button.x');
if (!btn) return;
const id = getEditorImageId();
const name = btn.dataset.name;
if (!id || !name) return;
await removeTag(id, name);
});
}
// ---------------------------
// Existing viewer logic
// ---------------------------
function updateImage(index) {
const img = images[index];
const fileType = img.dataset.type || "image";
const imageId = img.dataset.id || "";
// clear wrapper
modalImageWrapper.innerHTML = "";
if (fileType === "video") {
const video = document.createElement("video");
video.src = img.dataset.full;
video.controls = true;
video.autoplay = true;
video.style.maxWidth = "100%";
video.style.maxHeight = "100%";
modalImageWrapper.appendChild(video);
} else {
const image = document.createElement("img");
image.src = img.dataset.full;
image.alt = "Full View";
image.setAttribute("draggable", "false");
image.id = "modalImage";
modalImageWrapper.appendChild(image);
}
currentIndex = index;
resetZoom();
// NEW: load tags for this image into the modal editor
setEditorImageId(imageId);
if (tagInput) tagInput.value = '';
clearFandomPicker();
loadTags(imageId);
loadSeriesInfo(imageId);
// Prefill the reserved autocomplete area with top tags so it's never empty.
fetchAutocomplete('');
}
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);
}
function closeModal() {
clearFandomPicker();
// Get the image ID before clearing so we can refresh its tags
const imageId = getEditorImageId();
modal.classList.remove('active');
modalImageWrapper.innerHTML = '';
currentIndex = -1;
resetZoom();
// clear tag UI
setEditorImageId('');
if (tagInput) tagInput.value = '';
renderTags([]);
renderProvenance([]);
clearSuggestions();
clearAutocomplete();
hideSeriesInfo();
hideAddToSeries();
history.replaceState({}, '', window.location.pathname + window.location.search);
// Refresh the gallery item's tags display (if function available from bulk-select.js)
if (imageId && window.refreshItemTags) {
window.refreshItemTags(imageId);
}
}
function showNext() {
if (currentIndex < images.length - 1) {
updateImage(currentIndex + 1);
} else if (isInfiniteScrollMode) {
// In infinite scroll mode, just stay at the last image
// User needs to scroll the gallery to load more
return;
} else if (hasNextPageInput?.value === "true") {
// Legacy pagination mode - fetch next page
fetch(nextPageUrlInput.value)
.then(res => res.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newGallery = doc.querySelector('.gallery-grid');
const newImages = doc.querySelectorAll('.img-clickable');
if (newGallery && newImages.length > 0) {
const gallery = document.querySelector('.gallery-grid');
gallery.innerHTML = newGallery.innerHTML;
images = Array.from(document.querySelectorAll('.img-clickable'));
images.forEach((img, i) => {
img.addEventListener('click', () => openModal(i));
});
hasNextPageInput.value = doc.getElementById('hasNextPage')?.value || "false";
nextPageUrlInput.value = doc.getElementById('nextPageUrl')?.value || "";
hasPrevPageInput.value = doc.getElementById('hasPrevPage')?.value || "false";
prevPageUrlInput.value = doc.getElementById('prevPageUrl')?.value || "";
history.pushState({}, '', nextPageUrlInput.value);
openModal(0);
}
});
}
}
function showPrev() {
if (currentIndex > 0) {
updateImage(currentIndex - 1);
} else if (isInfiniteScrollMode) {
// In infinite scroll mode, just stay at the first image
return;
} else if (hasPrevPageInput?.value === "true") {
// Legacy pagination mode - fetch previous page
fetch(prevPageUrlInput.value)
.then(res => res.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newGallery = doc.querySelector('.gallery-grid');
const newImages = doc.querySelectorAll('.img-clickable');
if (newGallery && newImages.length > 0) {
const gallery = document.querySelector('.gallery-grid');
gallery.innerHTML = newGallery.innerHTML;
images = Array.from(document.querySelectorAll('.img-clickable'));
images.forEach((img, i) => {
img.addEventListener('click', () => openModal(i));
});
hasNextPageInput.value = doc.getElementById('hasNextPage')?.value || "false";
nextPageUrlInput.value = doc.getElementById('nextPageUrl')?.value || "";
hasPrevPageInput.value = doc.getElementById('hasPrevPage')?.value || "false";
prevPageUrlInput.value = doc.getElementById('prevPageUrl')?.value || "";
history.pushState({}, '', prevPageUrlInput.value);
openModal(images.length - 1);
}
});
}
}
function toggleZoom() {
const isImage = modalImageWrapper.querySelector('img') !== null;
if (!isImage) return;
isZoomed = !isZoomed;
modalImageWrapper.classList.toggle('zoomed', isZoomed);
modalImageWrapper.style.cursor = isZoomed ? 'grab' : '';
if (!isZoomed) {
modalImageWrapper.scrollLeft = 0;
modalImageWrapper.scrollTop = 0;
}
}
function resetZoom() {
isZoomed = false;
modalImageWrapper.classList.remove('zoomed');
modalImageWrapper.scrollLeft = 0;
modalImageWrapper.scrollTop = 0;
modalImageWrapper.style.cursor = '';
}
modalImageWrapper.addEventListener('mousedown', (e) => {
const isImage = modalImageWrapper.querySelector('img') !== null;
if (!isZoomed || !isImage) return;
isDragging = true;
dragMoved = false;
modalImageWrapper.style.cursor = 'grabbing';
startX = e.pageX - modalImageWrapper.offsetLeft;
startY = e.pageY - modalImageWrapper.offsetTop;
scrollLeft = modalImageWrapper.scrollLeft;
scrollTop = modalImageWrapper.scrollTop;
dragStartX = e.pageX;
dragStartY = e.pageY;
});
modalImageWrapper.addEventListener('mouseleave', () => {
isDragging = false;
const isImage = modalImageWrapper.querySelector('img') !== null;
if (isZoomed && isImage) modalImageWrapper.style.cursor = 'grab';
});
modalImageWrapper.addEventListener('mouseup', () => {
isDragging = false;
const isImage = modalImageWrapper.querySelector('img') !== null;
if (isZoomed && isImage) modalImageWrapper.style.cursor = 'grab';
});
modalImageWrapper.addEventListener('mousemove', (e) => {
const isImage = modalImageWrapper.querySelector('img') !== null;
if (!isDragging || !isZoomed || !isImage) return;
e.preventDefault();
dragMoved = true;
const x = e.pageX - modalImageWrapper.offsetLeft;
const y = e.pageY - modalImageWrapper.offsetTop;
const walkX = x - startX;
const walkY = y - startY;
modalImageWrapper.scrollLeft = scrollLeft - walkX;
modalImageWrapper.scrollTop = scrollTop - walkY;
});
modalImageWrapper.addEventListener('click', (e) => {
if (dragMoved) {
dragMoved = false;
return;
}
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);
modal.addEventListener('click', (e) => {
if (e.target === modal) closeModal();
});
document.addEventListener('keydown', (e) => {
if (!modal.classList.contains('active')) return;
if (e.key === 'ArrowRight') showNext();
if (e.key === 'ArrowLeft') showPrev();
if (e.key === 'Escape') closeModal();
});
// Use event delegation on the document for .img-clickable clicks
// This allows dynamically added elements (e.g., from shuffle) to work
document.addEventListener('click', (e) => {
const clickable = e.target.closest('.img-clickable');
if (!clickable) return;
// Re-query images to get current state (may have changed from shuffle)
images = Array.from(document.querySelectorAll('.img-clickable'));
const index = images.indexOf(clickable);
if (index !== -1) {
openModal(index);
}
});
// Listen for showcase updates to refresh the images array
document.addEventListener('showcaseUpdated', () => {
images = Array.from(document.querySelectorAll('.img-clickable'));
});
// Listen for gallery infinite scroll updates to refresh the images array
document.addEventListener('galleryUpdated', () => {
images = Array.from(document.querySelectorAll('.img-clickable'));
});
window.addEventListener('popstate', () => {
if (modal.classList.contains('active')) {
closeModal();
} else {
location.reload();
}
});
if (suggestionsRefresh) {
suggestionsRefresh.addEventListener('click', () => {
const currentId = getEditorImageId();
if (currentId) loadSuggestions(currentId);
});
}
});