From 3cd6aa2a6a5ca94d6bba079ea1d774944c5c25b6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 16:11:47 -0400 Subject: [PATCH] feat(modal): fandom picker wiring Reveals the picker on 'character:' prefix; autocomplete via /api/tags/search?kind=fandom; submits fandom_id (preferred) or fandom_name alongside the character name. Clears on image switch and modal close. Co-Authored-By: Claude Opus 4.7 --- app/static/js/view-modal.js | 103 ++++++++++++++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 3 deletions(-) diff --git a/app/static/js/view-modal.js b/app/static/js/view-modal.js index b800220..ee16602 100644 --- a/app/static/js/view-modal.js +++ b/app/static/js/view-modal.js @@ -14,6 +14,11 @@ document.addEventListener('DOMContentLoaded', () => { 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'); @@ -590,9 +595,14 @@ document.addEventListener('DOMContentLoaded', () => { }); } - async function addTag(imageId, name) { + 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) { @@ -615,6 +625,35 @@ document.addEventListener('DOMContentLoaded', () => { 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 // --------------------------- @@ -681,8 +720,14 @@ document.addEventListener('DOMContentLoaded', () => { const id = getEditorImageId(); const name = (tagInput?.value || '').trim(); if (!id || !name) return; - await addTag(id, name); + 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(''); } @@ -693,8 +738,22 @@ document.addEventListener('DOMContentLoaded', () => { fetchAutocomplete(tagInput.value.trim()); }); - // Search as user types. + // 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()); @@ -742,6 +801,42 @@ document.addEventListener('DOMContentLoaded', () => { }); } + 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(); @@ -793,6 +888,7 @@ document.addEventListener('DOMContentLoaded', () => { // 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. @@ -812,6 +908,7 @@ document.addEventListener('DOMContentLoaded', () => { } function closeModal() { + clearFandomPicker(); // Get the image ID before clearing so we can refresh its tags const imageId = getEditorImageId();