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>
|
<template>
|
||||||
<div class="fc-tag-autocomplete">
|
<div class="fc-tag-autocomplete">
|
||||||
<div class="d-flex" style="gap: 6px;">
|
<v-text-field
|
||||||
<v-select
|
v-model="query"
|
||||||
v-model="kind" :items="kindOptions" :item-title="(k) => k.label" :item-value="(k) => k.value"
|
placeholder="Add tag (or kind:name — artist/character/fandom/series/meta/rating)"
|
||||||
density="compact" hide-details style="max-width: 140px;"
|
density="compact" hide-details
|
||||||
/>
|
@keydown.down.prevent="moveHighlight(1)"
|
||||||
<v-text-field
|
@keydown.up.prevent="moveHighlight(-1)"
|
||||||
v-model="query" placeholder="Add tag…" density="compact" hide-details
|
@keydown.enter.prevent="onEnter"
|
||||||
@keydown.down.prevent="moveHighlight(1)" @keydown.up.prevent="moveHighlight(-1)"
|
@keydown.esc="$emit('cancel')"
|
||||||
@keydown.enter.prevent="onEnter" @keydown.esc="$emit('cancel')"
|
/>
|
||||||
/>
|
<v-list
|
||||||
</div>
|
v-if="hits.length || allowCreate"
|
||||||
<v-list v-if="hits.length || allowCreate" density="compact" class="fc-tag-autocomplete__list">
|
density="compact" class="fc-tag-autocomplete__list"
|
||||||
|
>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="(h, idx) in hits" :key="h.id"
|
v-for="(h, idx) in hits" :key="h.id"
|
||||||
:active="idx === highlight" @click="onPick(h)"
|
:active="idx === highlight" @click="onPick(h)"
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
<span v-if="h.fandom_name" class="text-caption">— {{ h.fandom_name }}</span>
|
<span v-if="h.fandom_name" class="text-caption">— {{ h.fandom_name }}</span>
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
<template #append>
|
<template #append>
|
||||||
<span class="text-caption">{{ h.image_count }}</span>
|
<span class="text-caption">{{ h.kind }}</span>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
@@ -34,9 +35,13 @@
|
|||||||
@click="onCreate"
|
@click="onCreate"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<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>
|
</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-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
|
|
||||||
@@ -54,65 +59,96 @@ import FandomPicker from './FandomPicker.vue'
|
|||||||
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
|
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
|
||||||
const store = useTagStore()
|
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 query = ref('')
|
||||||
const hits = ref([])
|
const hits = ref([])
|
||||||
const highlight = ref(0)
|
const highlight = ref(0)
|
||||||
const fandomDialog = ref(false)
|
const fandomDialog = ref(false)
|
||||||
let pendingNewName = null
|
let pendingNewName = null
|
||||||
|
|
||||||
const kindOptions = store.kindOptions()
|
const KNOWN_KINDS = new Set([
|
||||||
|
'artist', 'character', 'fandom', 'series', 'meta', 'rating',
|
||||||
|
])
|
||||||
const KIND_ICONS = {
|
const KIND_ICONS = {
|
||||||
general: 'mdi-tag', artist: 'mdi-palette', character: 'mdi-account-circle',
|
general: 'mdi-tag', artist: 'mdi-palette', character: 'mdi-account-circle',
|
||||||
fandom: 'mdi-book-open-page-variant', series: 'mdi-bookshelf',
|
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
|
let debounceId = null
|
||||||
watch([query, kind], () => {
|
watch(query, () => {
|
||||||
highlight.value = 0
|
highlight.value = 0
|
||||||
if (debounceId) clearTimeout(debounceId)
|
if (debounceId) clearTimeout(debounceId)
|
||||||
debounceId = setTimeout(async () => {
|
debounceId = setTimeout(async () => {
|
||||||
const q = query.value.trim()
|
const q = parsedName.value
|
||||||
if (!q) { hits.value = []; return }
|
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)
|
}, 200)
|
||||||
})
|
})
|
||||||
|
|
||||||
const allowCreate = computed(() => {
|
const allowCreate = computed(() => {
|
||||||
const q = query.value.trim()
|
const q = parsedName.value
|
||||||
return q && !hits.value.some(h => h.name.toLowerCase() === q.toLowerCase() && h.kind === kind.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)
|
const total = hits.value.length + (allowCreate.value ? 1 : 0)
|
||||||
if (total === 0) return
|
if (total === 0) return
|
||||||
highlight.value = (highlight.value + delta + total) % total
|
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() {
|
function onCreate () {
|
||||||
const name = query.value.trim()
|
const name = parsedName.value
|
||||||
if (kind.value === 'character') {
|
const kind = parsedKind.value
|
||||||
// Character requires a fandom — open the picker.
|
if (kind === 'character') {
|
||||||
pendingNewName = name
|
pendingNewName = name
|
||||||
fandomDialog.value = true
|
fandomDialog.value = true
|
||||||
return
|
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()
|
reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFandomChosen(fandom) {
|
function onFandomChosen (fandom) {
|
||||||
fandomDialog.value = false
|
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
|
pendingNewName = null
|
||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEnter() {
|
function onEnter () {
|
||||||
if (highlight.value < hits.value.length) {
|
if (highlight.value < hits.value.length) {
|
||||||
onPick(hits.value[highlight.value])
|
onPick(hits.value[highlight.value])
|
||||||
} else if (allowCreate.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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user