character-fandom tag association system

Link character tags to fandom tags via fandom_id FK with auto-apply
behavior. Adding character:Saber (Fate) auto-creates fandom:Fate and
applies it to the image. Renaming a fandom cascades to all linked
characters. Includes set-fandom endpoint for retroactive association
and fandom selector dropdown in the tag editor UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 19:53:02 -05:00
parent 3a30b876a2
commit bf20eeab5c
6 changed files with 362 additions and 18 deletions
+121 -1
View File
@@ -11,7 +11,95 @@ document.addEventListener('DOMContentLoaded', () => {
const nameInput = document.getElementById('editTagName');
const kindSelect = document.getElementById('editTagKind');
// Fandom UI elements
const fandomGroup = document.getElementById('fandomGroup');
const fandomInput = document.getElementById('editTagFandom');
const fandomIdInput = document.getElementById('editTagFandomId');
const fandomAutocomplete = document.getElementById('fandomAutocomplete');
let currentTagCard = null;
let fandomDebounce = null;
// Show/hide fandom selector based on kind
function updateFandomVisibility() {
if (fandomGroup) {
fandomGroup.style.display = kindSelect.value === 'character' ? '' : 'none';
}
}
kindSelect?.addEventListener('change', updateFandomVisibility);
// Fandom autocomplete search
async function searchFandoms(query) {
try {
const params = new URLSearchParams({ q: query, kind: 'fandom', limit: 10 });
const res = await fetch(`/api/tags/search?${params}`);
const data = await res.json();
return data.tags || [];
} catch (err) {
console.error('Failed to search fandoms:', err);
return [];
}
}
function renderFandomAutocomplete(tags) {
if (!fandomAutocomplete) return;
if (tags.length === 0) {
fandomAutocomplete.innerHTML = '<div class="tag-autocomplete-empty">No matching fandoms</div>';
fandomAutocomplete.style.display = 'block';
return;
}
fandomAutocomplete.innerHTML = tags.map((t, i) => {
const displayName = t.name.includes(':') ? t.name.split(':')[1] : t.name;
return `<div class="tag-autocomplete-item" data-index="${i}" data-id="${t.id}" data-name="${displayName}">
<span class="tag-autocomplete-name">🎭 ${displayName}</span>
</div>`;
}).join('');
fandomAutocomplete.style.display = 'block';
}
// Fandom input events
fandomInput?.addEventListener('input', () => {
clearTimeout(fandomDebounce);
fandomDebounce = setTimeout(async () => {
const query = fandomInput.value.trim();
if (query.length < 1) {
fandomAutocomplete.style.display = 'none';
fandomIdInput.value = '';
return;
}
const tags = await searchFandoms(query);
renderFandomAutocomplete(tags);
// Clear the hidden ID since user is typing a new value
fandomIdInput.value = '';
}, 200);
});
fandomInput?.addEventListener('focus', async () => {
const query = fandomInput.value.trim();
const tags = await searchFandoms(query || '');
renderFandomAutocomplete(tags);
});
fandomInput?.addEventListener('blur', () => {
// Delay to allow click on autocomplete item
setTimeout(() => {
if (fandomAutocomplete) fandomAutocomplete.style.display = 'none';
}, 200);
});
// Click on fandom autocomplete item
fandomAutocomplete?.addEventListener('mousedown', (e) => {
e.preventDefault();
const item = e.target.closest('.tag-autocomplete-item');
if (!item) return;
fandomInput.value = item.dataset.name;
fandomIdInput.value = item.dataset.id;
fandomAutocomplete.style.display = 'none';
});
// Open modal when edit button clicked (using event delegation for infinite scroll)
document.addEventListener('click', async (e) => {
@@ -33,6 +121,17 @@ document.addEventListener('DOMContentLoaded', () => {
tagIdInput.value = data.tag.id;
nameInput.value = data.tag.display_name;
kindSelect.value = data.tag.kind || 'user';
// Set fandom fields
if (data.tag.fandom_id && data.tag.fandom_name) {
fandomInput.value = data.tag.fandom_name;
fandomIdInput.value = data.tag.fandom_id;
} else {
fandomInput.value = '';
fandomIdInput.value = '';
}
updateFandomVisibility();
modal.classList.add('active');
}
} catch (err) {
@@ -44,6 +143,7 @@ document.addEventListener('DOMContentLoaded', () => {
function closeModal() {
modal.classList.remove('active');
currentTagCard = null;
if (fandomAutocomplete) fandomAutocomplete.style.display = 'none';
}
closeBtn?.addEventListener('click', closeModal);
@@ -76,8 +176,28 @@ document.addEventListener('DOMContentLoaded', () => {
const data = await res.json();
if (data.ok) {
// After successful update, handle fandom association for character tags
if (kindSelect.value === 'character') {
const fandomName = fandomInput.value.trim();
const fandomId = fandomIdInput.value;
const updateTagId = data.tag.id;
// Set fandom via the dedicated endpoint
const fandomFormData = new FormData();
if (fandomId) {
fandomFormData.append('fandom_id', fandomId);
} else if (fandomName) {
fandomFormData.append('fandom_name', fandomName);
}
// If both empty, this clears the fandom
await fetch(`/api/tag/${updateTagId}/set-fandom`, {
method: 'POST',
body: fandomFormData
});
}
if (data.merged) {
// Tag was merged
alert(`Tags merged successfully! ${data.merged_count} images were updated.`);
}
// Reload page to show updated tag