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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user