feat(gallery): OR/exclude tag filtering — light chips + advanced builder (#6b/c/d)
Cluster A, milestone #97. Completes the frontend of the structured tag filter
(backend landed in 23fab98).
#6b store: gallery.filter gains tag_or (OR-groups) + tag_exclude; one model,
serialized via tag_or (repeated key) + tag_not across activeFilterParam/
filterToQuery/applyFilterFromQuery/cloneFilter/loadSimilar; _resolveLabels
resolves names for every referenced id. useApi now appends array param values
as a repeated key (tag_or=a&tag_or=b).
#6c light editor (GalleryFilterBar): autocomplete pick → include chip; click a
chip body to flip include↔exclude (exclude = red minus); ✕ removes. An
"N OR-groups" chip + an Advanced button open the builder.
#6d advanced editor (TagQueryBuilder.vue + common/TagPicker.vue): AND-of-OR
group builder + NOT list. Unifies includes+OR-groups into one groups view,
splits back to tag_ids/tag_or on Apply so the URL stays compact. Writes the
same model the light chips edit.
Store serialization round-trip tests added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<!-- Thin debounced tag-search autocomplete: emits `pick` with the chosen tag
|
||||
and self-clears. Shared by the gallery's advanced tag-query builder; the
|
||||
filter bar's own inline search predates this and also folds in artists. -->
|
||||
<v-autocomplete
|
||||
v-model="selected"
|
||||
:items="items"
|
||||
:loading="loading"
|
||||
item-title="name" item-value="id"
|
||||
no-filter hide-details density="compact" variant="outlined"
|
||||
:placeholder="placeholder"
|
||||
prepend-inner-icon="mdi-tag-plus-outline"
|
||||
:aria-label="placeholder"
|
||||
@update:search="onSearch"
|
||||
@update:model-value="onPick"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps" :title="item.raw.name">
|
||||
<template #subtitle>
|
||||
{{ item.raw.fandom_name ? `character · ${item.raw.fandom_name}` : item.raw.kind }}
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template #no-data>
|
||||
<v-list-item :title="searchedOnce ? 'No tags match' : 'Type to search tags'" />
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
import { useApi } from '../../composables/useApi.js'
|
||||
|
||||
defineProps({ placeholder: { type: String, default: 'Add tag…' } })
|
||||
const emit = defineEmits(['pick'])
|
||||
|
||||
const api = useApi()
|
||||
const selected = ref(null)
|
||||
const items = ref([])
|
||||
const loading = ref(false)
|
||||
const searchedOnce = ref(false)
|
||||
let debounce = null
|
||||
|
||||
function onSearch (q) {
|
||||
if (debounce) clearTimeout(debounce)
|
||||
if (!q || !q.trim()) { items.value = []; return }
|
||||
debounce = setTimeout(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
items.value = (await api.get('/api/tags/autocomplete', { params: { q, limit: 10 } })) || []
|
||||
} catch {
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
searchedOnce.value = true
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function onPick (id) {
|
||||
const tag = items.value.find((i) => i.id === id)
|
||||
selected.value = null
|
||||
items.value = []
|
||||
if (tag) emit('pick', tag)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => { if (debounce) clearTimeout(debounce) })
|
||||
</script>
|
||||
Reference in New Issue
Block a user