// /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} `; 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 = ` ${escapeHtml(suggestion.name)} ${pct}% `; 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) { clearSuggestions(); return; } // Render regular suggestions first — it wipes innerHTML. Then prepend // the auto-accepted block so both coexist. renderSuggestions(j.suggestions || {}, imageId); renderAutoAccepted(j.auto_accepted || [], imageId); if ((j.auto_accepted || []).length > 0) { suggestionsSection.style.display = ''; } // Auto-accepted tags are now attached to the image — refresh the // pill list so the user sees them in the Tags section. if ((j.auto_accepted || []).length > 0 && typeof loadTags === 'function') { await loadTags(imageId); } } catch { clearSuggestions(); } } function renderAutoAccepted(items, imageId) { if (!suggestionsList) return; // Remove any prior auto-accepted block from the current modal view. suggestionsList.querySelectorAll('.suggestions-auto-accepted').forEach(el => el.remove()); if (!items || items.length === 0) return; const block = document.createElement('div'); block.className = 'suggestions-auto-accepted'; const label = document.createElement('div'); label.className = 'suggestions-category-label'; label.textContent = `Auto-accepted (${items.length})`; block.appendChild(label); const chips = document.createElement('div'); chips.className = 'suggestions-chips'; for (const item of items) { chips.appendChild(buildAutoAcceptedChip(item, imageId)); } block.appendChild(chips); suggestionsList.prepend(block); } function buildAutoAcceptedChip(item, imageId) { const chip = document.createElement('div'); chip.className = 'suggestion-chip suggestion-chip-auto'; chip.dataset.tagId = String(item.id); chip.dataset.tagName = item.name; chip.dataset.source = item.source || 'wd14'; chip.dataset.confidence = String(item.confidence || 0); chip.title = `Auto-accepted from ${item.source || 'wd14'} at ${Math.round((item.confidence || 0) * 100)}%`; const pct = Math.round((item.confidence || 0) * 100); chip.innerHTML = ` ${escapeHtml(item.display_name || item.name)} ${pct}% `; chip.querySelector('.sugg-undo')?.addEventListener('click', () => undoAutoAccepted(chip, imageId)); chip.querySelector('.sugg-block')?.addEventListener('click', () => blocklistSuggestion(chip, imageId)); return chip; } async function undoAutoAccepted(chip, imageId) { if (chip.dataset.disabled === 'true') return; chip.dataset.disabled = 'true'; chip.style.pointerEvents = 'none'; const name = chip.dataset.tagName; try { // Remove the tag from this image (uses the same remove endpoint the // manual tag-pill '×' button uses). 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) { chip.dataset.disabled = 'false'; chip.style.pointerEvents = ''; if (tagActionFeedback) tagActionFeedback.textContent = `Couldn't undo: ${j.error || 'error'}`; return; } // Log the rejection for ML feedback so future threshold tuning sees this. try { await fetch(`/image/${imageId}/suggestions/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tag_name: name, source: chip.dataset.source || 'wd14', confidence: Number(chip.dataset.confidence) || 0, }), }); } catch { // best-effort } if (tagActionFeedback) tagActionFeedback.textContent = `Removed '${name}'`; animateChipOut(chip); if (typeof loadTags === 'function') loadTags(imageId); } catch { chip.dataset.disabled = 'false'; chip.style.pointerEvents = ''; } } 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} ${placeholder} `; 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 = ''; 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, '"'); const displayName = String(r.display_name || r.name || '').replace(/${displayName}`; }).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 ? `
${escapeHtml(placeholder)}
` : ''; } autocompleteItems = []; autocompleteSelectedIndex = -1; } function renderAutocomplete(tags) { if (!tagAutocomplete) return; autocompleteItems = tags; autocompleteSelectedIndex = -1; if (tags.length === 0) { tagAutocomplete.innerHTML = '
No matching tags
'; 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 `
${icon} ${label} ${t.kind || 'user'}
`; }).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); }); } });