feat(fc2a): add tag store, TagAutocomplete, and FandomPicker

TagAutocomplete: kind selector + debounced (200ms) search + keyboard nav
(up/down/enter/esc). When the operator types a character that doesn't
exist, the create flow first opens FandomPicker so the new character tag
lands with a valid fandom_id (otherwise the API rejects it).

FandomPicker lists existing fandoms (cached after first load) with an
inline create-new affordance, so the operator never has to leave the modal
to manage fandom hierarchies.

Tag store also exposes a kind→icon→color mapping that the TagPanel reuses
in Task 22 — keeping chip colors visually distinct per kind without per-app
accent collisions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 12:34:17 -04:00
parent 0faeebf3aa
commit 89fee260a6
3 changed files with 241 additions and 0 deletions
@@ -0,0 +1,48 @@
<template>
<v-card>
<v-card-title>Pick a fandom</v-card-title>
<v-card-text>
<v-autocomplete
v-model="selectedId"
:items="store.fandomCache"
:item-title="(f) => f.name"
:item-value="(f) => f.id"
label="Fandom" clearable density="compact"
/>
<v-divider class="my-3" />
<p class="text-caption mb-2">Or create a new fandom:</p>
<div class="d-flex" style="gap: 8px;">
<v-text-field v-model="newName" placeholder="New fandom name" density="compact" hide-details />
<v-btn :disabled="!newName.trim()" rounded="pill" @click="onCreate">Create</v-btn>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="$emit('cancel')">Cancel</v-btn>
<v-btn :disabled="!selectedId" color="primary" rounded="pill" @click="onConfirm">Use this fandom</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useTagStore } from '../../stores/tags.js'
const emit = defineEmits(['confirm', 'cancel'])
const store = useTagStore()
const selectedId = ref(null)
const newName = ref('')
onMounted(() => { if (store.fandomCache.length === 0) store.loadFandoms() })
async function onCreate() {
const f = await store.createFandom(newName.value.trim())
selectedId.value = f.id
newName.value = ''
}
function onConfirm() {
const f = store.fandomCache.find(x => x.id === selectedId.value)
if (f) emit('confirm', f)
}
</script>
@@ -0,0 +1,135 @@
<template>
<div class="fc-tag-autocomplete">
<div class="d-flex" style="gap: 6px;">
<v-select
v-model="kind" :items="kindOptions" :item-title="(k) => k.label" :item-value="(k) => k.value"
density="compact" hide-details style="max-width: 140px;"
/>
<v-text-field
v-model="query" placeholder="Add tag…" density="compact" hide-details
@keydown.down.prevent="moveHighlight(1)" @keydown.up.prevent="moveHighlight(-1)"
@keydown.enter.prevent="onEnter" @keydown.esc="$emit('cancel')"
/>
</div>
<v-list v-if="hits.length || allowCreate" density="compact" class="fc-tag-autocomplete__list">
<v-list-item
v-for="(h, idx) in hits" :key="h.id"
:active="idx === highlight" @click="onPick(h)"
>
<template #prepend>
<v-icon size="small" :color="store.colorFor(h.kind)">
{{ iconFor(h.kind) }}
</v-icon>
</template>
<v-list-item-title>
{{ h.name }}
<span v-if="h.fandom_name" class="text-caption"> {{ h.fandom_name }}</span>
</v-list-item-title>
<template #append>
<span class="text-caption">{{ h.image_count }}</span>
</template>
</v-list-item>
<v-list-item
v-if="allowCreate" :active="highlight === hits.length"
@click="onCreate"
>
<template #prepend>
<v-icon size="small" :color="store.colorFor(kind)">{{ iconFor(kind) }}</v-icon>
</template>
<v-list-item-title>Create "{{ query }}" as {{ kind }}</v-list-item-title>
</v-list-item>
</v-list>
<v-dialog v-model="fandomDialog" max-width="480">
<FandomPicker @confirm="onFandomChosen" @cancel="fandomDialog = false" />
</v-dialog>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useTagStore } from '../../stores/tags.js'
import FandomPicker from './FandomPicker.vue'
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
const store = useTagStore()
const kind = ref('general')
const query = ref('')
const hits = ref([])
const highlight = ref(0)
const fandomDialog = ref(false)
let pendingNewName = null
const kindOptions = store.kindOptions()
const KIND_ICONS = {
general: 'mdi-tag', artist: 'mdi-palette', character: 'mdi-account-circle',
fandom: 'mdi-book-open-page-variant', series: 'mdi-bookshelf',
meta: 'mdi-cog-outline', rating: 'mdi-shield-check-outline'
}
function iconFor(k) { return KIND_ICONS[k] || 'mdi-tag' }
let debounceId = null
watch([query, kind], () => {
highlight.value = 0
if (debounceId) clearTimeout(debounceId)
debounceId = setTimeout(async () => {
const q = query.value.trim()
if (!q) { hits.value = []; return }
hits.value = await store.autocomplete(q, kind.value, 10)
}, 200)
})
const allowCreate = computed(() => {
const q = query.value.trim()
return q && !hits.value.some(h => h.name.toLowerCase() === q.toLowerCase() && h.kind === kind.value)
})
function moveHighlight(delta) {
const total = hits.value.length + (allowCreate.value ? 1 : 0)
if (total === 0) return
highlight.value = (highlight.value + delta + total) % total
}
function onPick(hit) { emit('pick-existing', hit); reset() }
function onCreate() {
const name = query.value.trim()
if (kind.value === 'character') {
// Character requires a fandom — open the picker.
pendingNewName = name
fandomDialog.value = true
return
}
emit('pick-new', { name, kind: kind.value, fandom_id: null })
reset()
}
function onFandomChosen(fandom) {
fandomDialog.value = false
emit('pick-new', { name: pendingNewName, kind: 'character', fandom_id: fandom.id })
pendingNewName = null
reset()
}
function onEnter() {
if (highlight.value < hits.value.length) {
onPick(hits.value[highlight.value])
} else if (allowCreate.value) {
onCreate()
}
}
function reset() { query.value = ''; hits.value = []; highlight.value = 0 }
</script>
<style scoped>
.fc-tag-autocomplete__list {
margin-top: 4px;
max-height: 240px;
overflow-y: auto;
background: rgb(var(--v-theme-surface));
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 6px;
}
</style>
+58
View File
@@ -0,0 +1,58 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
const KIND_OPTIONS = [
{ value: 'general', label: 'General', icon: 'mdi-tag' },
{ value: 'artist', label: 'Artist', icon: 'mdi-palette' },
{ value: 'character', label: 'Character', icon: 'mdi-account-circle' },
{ value: 'fandom', label: 'Fandom', icon: 'mdi-book-open-page-variant' },
{ value: 'series', label: 'Series', icon: 'mdi-bookshelf' },
{ value: 'meta', label: 'Meta', icon: 'mdi-cog-outline' },
{ value: 'rating', label: 'Rating', icon: 'mdi-shield-check-outline' }
]
const KIND_COLOR = {
artist: 'accent',
character: 'info',
fandom: 'secondary',
series: 'warning',
general: 'on-surface',
meta: 'on-surface',
rating: 'on-surface',
archive: 'on-surface',
post: 'on-surface'
}
export const useTagStore = defineStore('tags', () => {
const api = useApi()
const fandomCache = ref([])
async function autocomplete(q, kind = null, limit = 20) {
if (!q || !q.trim()) return []
const params = { q, limit }
if (kind) params.kind = kind
return await api.get('/api/tags/autocomplete', { params })
}
async function loadFandoms() {
fandomCache.value = await api.get('/api/tags/autocomplete', {
params: { q: ' ', kind: 'fandom', limit: 200 }
})
return fandomCache.value
}
async function createFandom(name) {
const fandom = await api.post('/api/tags', { body: { name, kind: 'fandom' } })
fandomCache.value = [...fandomCache.value, {
id: fandom.id, name: fandom.name, kind: 'fandom',
fandom_id: null, fandom_name: null, image_count: 0
}]
return fandom
}
function kindOptions() { return KIND_OPTIONS }
function colorFor(kind) { return KIND_COLOR[kind] || 'on-surface' }
return { fandomCache, autocomplete, loadFandoms, createFandom, kindOptions, colorFor }
})