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