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 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 16:11:47 -04:00
parent a6bcc1c088
commit 3cd6aa2a6a
+100 -3
View File
@@ -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, '&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
// ---------------------------
@@ -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();