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