feat(tag-input): IR-style kind:name suffix — drop the kind dropdown from TagAutocomplete; client-side parser mirrors backend's parse_kind_prefix (KNOWN_KINDS = artist/character/fandom/series/meta/rating); autocomplete searches across all kinds and shows kind chip in results; Create label uses parsed kind; character flow still goes through FandomPicker

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 20:54:11 -04:00
parent 8cdf0af0e1
commit 3b1e2f1ceb
@@ -1,17 +1,18 @@
<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-text-field
v-model="query"
placeholder="Add tag (or kind:name — artist/character/fandom/series/meta/rating)"
density="compact" hide-details
@keydown.down.prevent="moveHighlight(1)"
@keydown.up.prevent="moveHighlight(-1)"
@keydown.enter.prevent="onEnter"
@keydown.esc="$emit('cancel')"
/>
<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)"
@@ -26,7 +27,7 @@
<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>
<span class="text-caption">{{ h.kind }}</span>
</template>
</v-list-item>
<v-list-item
@@ -34,9 +35,13 @@
@click="onCreate"
>
<template #prepend>
<v-icon size="small" :color="store.colorFor(kind)">{{ iconFor(kind) }}</v-icon>
<v-icon size="small" :color="store.colorFor(parsedKind)">
{{ iconFor(parsedKind) }}
</v-icon>
</template>
<v-list-item-title>Create "{{ query }}" as {{ kind }}</v-list-item-title>
<v-list-item-title>
Create "{{ parsedName }}" as {{ parsedKind }}
</v-list-item-title>
</v-list-item>
</v-list>
@@ -54,65 +59,96 @@ import FandomPicker from './FandomPicker.vue'
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
const store = useTagStore()
const kind = ref('general')
// Single text input; no kind dropdown. Client-side mirror of the
// backend's parse_kind_prefix lives below — kept in sync with
// KNOWN_KINDS in backend/app/utils/tag_prefix.py. The backend is the
// canonical parser; this mirror just powers the live Create-label
// preview ("Create 'Eric' as artist") and the autocomplete query.
const query = ref('')
const hits = ref([])
const highlight = ref(0)
const fandomDialog = ref(false)
let pendingNewName = null
const kindOptions = store.kindOptions()
const KNOWN_KINDS = new Set([
'artist', 'character', 'fandom', 'series', 'meta', 'rating',
])
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'
meta: 'mdi-cog-outline', rating: 'mdi-shield-check-outline',
}
function iconFor(k) { return KIND_ICONS[k] || 'mdi-tag' }
function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
const parsed = computed(() => {
const raw = query.value.trim()
if (raw.includes(':')) {
const idx = raw.indexOf(':')
const prefix = raw.slice(0, idx).toLowerCase()
if (KNOWN_KINDS.has(prefix)) {
return { kind: prefix, name: raw.slice(idx + 1).trim() }
}
}
return { kind: 'general', name: raw }
})
const parsedKind = computed(() => parsed.value.kind)
const parsedName = computed(() => parsed.value.name)
let debounceId = null
watch([query, kind], () => {
watch(query, () => {
highlight.value = 0
if (debounceId) clearTimeout(debounceId)
debounceId = setTimeout(async () => {
const q = query.value.trim()
const q = parsedName.value
if (!q) { hits.value = []; return }
hits.value = await store.autocomplete(q, kind.value, 10)
// Autocomplete across ALL kinds. When the user typed a prefix the
// matches list is naturally narrower because the parsed name is
// shorter; we don't filter server-side by kind.
hits.value = await store.autocomplete(q, null, 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)
const q = parsedName.value
if (!q) return false
return !hits.value.some(h =>
h.name.toLowerCase() === q.toLowerCase() && h.kind === parsedKind.value,
)
})
function moveHighlight(delta) {
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 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.
function onCreate () {
const name = parsedName.value
const kind = parsedKind.value
if (kind === 'character') {
pendingNewName = name
fandomDialog.value = true
return
}
emit('pick-new', { name, kind: kind.value, fandom_id: null })
// Pass explicit kind here; the backend accepts both shapes. Passing
// it makes the parsed kind preview match the actual server outcome
// for users who didn't use a prefix (general goes through cleanly).
emit('pick-new', { name, kind, fandom_id: null })
reset()
}
function onFandomChosen(fandom) {
function onFandomChosen (fandom) {
fandomDialog.value = false
emit('pick-new', { name: pendingNewName, kind: 'character', fandom_id: fandom.id })
emit('pick-new', {
name: pendingNewName, kind: 'character', fandom_id: fandom.id,
})
pendingNewName = null
reset()
}
function onEnter() {
function onEnter () {
if (highlight.value < hits.value.length) {
onPick(hits.value[highlight.value])
} else if (allowCreate.value) {
@@ -120,7 +156,7 @@ function onEnter() {
}
}
function reset() { query.value = ''; hits.value = []; highlight.value = 0 }
function reset () { query.value = ''; hits.value = []; highlight.value = 0 }
</script>
<style scoped>