// /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 ? `