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 c9fce48a39 refactor(js): use display_name from API instead of client-side split
Every user-facing tag rendering now reads tag.display_name from the
server JSON. The client-side split-on-colon fallback is removed.
Series-info link switches from ?tag=<name> to ?tag_id=<int>.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 16:16:38 -04:00

1226 lines
44 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);
const displayName = getTagDisplayName(t.name);
chip.innerHTML = `${icon} ${displayName} <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>
`;
const acceptBtn = chip.querySelector('.sugg-accept');
const rejectBtn = chip.querySelector('.sugg-reject');
if (acceptBtn) {
acceptBtn.addEventListener('click', () => acceptSuggestion(chip, imageId));
}
if (rejectBtn) {
rejectBtn.addEventListener('click', () => rejectSuggestion(chip, imageId));
}
return chip;
}
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, { fandomId, fandomName } = {}) {
const fd = new FormData();
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);
const displayName = getTagDisplayName(t.name);
return `<div class="tag-autocomplete-item" data-index="${i}" data-name="${t.name}">
<span>${icon} ${displayName}</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)
async function submitTag() {
const id = getEditorImageId();
const name = (tagInput?.value || '').trim();
if (!id || !name) return;
const fandomId = fandomPickerInput?.dataset?.fandomId;
const fandomName = (fandomPickerInput?.value || '').trim();
const ok = await addTag(id, name, {
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.name;
submitTag();
}
} 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;
if (tagInput && name) {
tagInput.value = name;
submitTag();
}
});
}
if (fandomPickerInput) {
fandomPickerInput.addEventListener('input', () => {
clearTimeout(fandomPickerDebounce);
const term = fandomPickerInput.value.trim();
// Any typing invalidates a previously-selected fandom id.
delete fandomPickerInput.dataset.fandomId;
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);
});
}
if (fandomPickerAutocomplete) {
fandomPickerAutocomplete.addEventListener('mousedown', (e) => {
const row = e.target.closest('[data-fandom-id]');
if (!row) return;
e.preventDefault(); // keep focus on the input
if (fandomPickerInput) {
fandomPickerInput.value = row.dataset.fandomName;
fandomPickerInput.dataset.fandomId = row.dataset.fandomId;
}
clearFandomAutocomplete();
});
}
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);
});
}
});