feat(gallery): OR/exclude tag filtering — light chips + advanced builder (#6b/c/d)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m15s

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:
2026-06-23 01:19:53 -04:00
parent 23fab983a0
commit 73dd301dbb
6 changed files with 500 additions and 3 deletions
@@ -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>
@@ -27,11 +27,34 @@
</v-autocomplete>
<div class="fc-filterbar__chips">
<!-- Include tag chips: click the body to flip to exclude, to remove
(#6 light editor one model, two editors). -->
<v-chip
v-for="id in store.filter.tag_ids" :key="`t${id}`"
size="small" closable :color="chipColor(id)" variant="tonal"
class="fc-filterbar__tagchip"
title="Click to exclude this tag instead"
@click="toggleTagPolarity(id)"
@click:close="removeTag(id)"
>{{ store.tagLabels[id] || `#${id}` }}</v-chip>
<!-- Exclude tag chips (red, minus). Click body to flip back to include. -->
<v-chip
v-for="id in store.filter.tag_exclude" :key="`x${id}`"
size="small" closable color="error" variant="tonal"
class="fc-filterbar__tagchip"
title="Excluded — click to include instead"
@click="toggleTagPolarity(id)"
@click:close="removeExclude(id)"
><v-icon start size="x-small">mdi-minus</v-icon>{{ store.tagLabels[id] || `#${id}` }}</v-chip>
<!-- Advanced OR-groups can't render as flat chips; one affordance opens
the builder where they live. -->
<v-chip
v-if="orGroupCount"
size="small" color="accent" variant="tonal"
prepend-icon="mdi-filter-cog-outline"
title="Open the advanced tag filter"
@click="advancedOpen = true"
>{{ orGroupCount }} OR-group{{ orGroupCount > 1 ? 's' : '' }}</v-chip>
<v-chip
v-if="store.filter.artist_id"
size="small" closable color="accent" variant="tonal"
@@ -68,6 +91,13 @@
@update:model-value="setSort"
/>
<v-btn
:color="advancedActive ? 'accent' : undefined"
:variant="advancedActive ? 'tonal' : 'text'"
size="small" prepend-icon="mdi-filter-cog-outline"
@click="advancedOpen = true"
>Advanced{{ advancedCount ? ` (${advancedCount})` : '' }}</v-btn>
<v-btn
:color="refineOpen ? 'accent' : undefined"
:variant="refineOpen || hasRefineFilters ? 'tonal' : 'text'"
@@ -83,6 +113,18 @@
</div>
<GalleryFacetPanel v-if="refineOpen" />
<v-dialog v-model="advancedOpen" max-width="640" scrollable>
<TagQueryBuilder
v-if="advancedOpen"
:include="store.filter.tag_ids"
:or-groups="store.filter.tag_or"
:exclude="store.filter.tag_exclude"
:labels="store.tagLabels"
@apply="onAdvancedApply"
@close="advancedOpen = false"
/>
</v-dialog>
</div>
</template>
@@ -93,6 +135,7 @@ import { useApi } from '../../composables/useApi.js'
import { cloneFilter, filterToQuery, useGalleryStore } from '../../stores/gallery.js'
import { useTagStore } from '../../stores/tags.js'
import GalleryFacetPanel from './GalleryFacetPanel.vue'
import TagQueryBuilder from './TagQueryBuilder.vue'
const store = useGalleryStore()
const tagStore = useTagStore()
@@ -117,8 +160,19 @@ const refineCount = computed(() => {
})
const hasRefineFilters = computed(() => refineCount.value > 0)
// The structured tag filter beyond plain includes (OR-groups + excludes) —
// drives the Advanced button's active state + its count badge.
const advancedOpen = ref(false)
const orGroupCount = computed(() => store.filter.tag_or.length)
const advancedCount = computed(
() => store.filter.tag_or.length + store.filter.tag_exclude.length,
)
const advancedActive = computed(() => advancedOpen.value || advancedCount.value > 0)
const hasActiveFilters = computed(() =>
store.filter.tag_ids.length > 0 ||
store.filter.tag_exclude.length > 0 ||
store.filter.tag_or.length > 0 ||
store.filter.artist_id != null ||
store.filter.media_type != null ||
store.filter.sort !== 'newest' ||
@@ -195,6 +249,34 @@ function onPick(value) {
function removeTag(id) {
pushFilter((n) => { n.tag_ids = n.tag_ids.filter((t) => t !== id) })
}
function removeExclude(id) {
pushFilter((n) => { n.tag_exclude = n.tag_exclude.filter((t) => t !== id) })
}
// Flip a tag between include and exclude in place (light-editor toggle). A tag
// is only ever in one of the two lists.
function toggleTagPolarity(id) {
pushFilter((n) => {
if (n.tag_ids.includes(id)) {
n.tag_ids = n.tag_ids.filter((t) => t !== id)
if (!n.tag_exclude.includes(id)) n.tag_exclude.push(id)
} else {
n.tag_exclude = n.tag_exclude.filter((t) => t !== id)
if (!n.tag_ids.includes(id)) n.tag_ids.push(id)
}
})
}
// The advanced builder hands back the whole tag model at once.
function onAdvancedApply({ tag_ids, tag_or, tag_exclude, labels }) {
for (const [id, name] of Object.entries(labels || {})) {
store.noteTagLabel(Number(id), name)
}
pushFilter((n) => {
n.tag_ids = tag_ids
n.tag_or = tag_or
n.tag_exclude = tag_exclude
})
advancedOpen.value = false
}
function clearArtist() {
store.noteArtistLabel(null)
pushFilter((n) => { n.artist_id = null })
@@ -257,6 +339,12 @@ function pushFilter(mutate) {
}
.fc-filterbar__search { max-width: 320px; min-width: 200px; }
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
/* The tag chips' bodies toggle include/exclude — signal they're clickable. */
.fc-filterbar__tagchip { cursor: pointer; }
.fc-filterbar__tagchip:focus-visible {
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: 1px;
}
.fc-filterbar__sort { max-width: 150px; }
/* Phones: the search's 200px min-width jams the wrapping bar. Give search its
@@ -0,0 +1,236 @@
<template>
<v-card class="fc-tqb">
<v-card-title class="fc-tqb__head">
<v-icon icon="mdi-filter-cog-outline" size="small" class="mr-2" />
Advanced tag filter
<v-spacer />
<v-btn
icon="mdi-close" variant="text" size="small"
aria-label="Close advanced tag filter" @click="$emit('close')"
/>
</v-card-title>
<v-card-text>
<p class="text-body-2 fc-tqb__hint">
An image must match <strong>every</strong> group below, and a group
matches when the image carries <strong>any</strong> tag in it
(AND of ORs). Excluded tags are never shown.
</p>
<!-- AND-of-OR groups -->
<div class="fc-tqb__section">
<div class="fc-tqb__section-title">Must match all of</div>
<p v-if="!groups.length" class="fc-tqb__empty">
No groups yet add a tag below to start.
</p>
<template v-for="(g, gi) in groups" :key="gi">
<div v-if="gi > 0" class="fc-tqb__and">AND</div>
<div class="fc-tqb__group">
<div class="fc-tqb__group-chips">
<template v-for="(id, ci) in g" :key="id">
<span v-if="ci > 0" class="fc-tqb__or">or</span>
<v-chip
size="small" closable variant="tonal"
:color="kindColor(id)"
@click:close="removeFromGroup(gi, id)"
>{{ label(id) }}</v-chip>
</template>
<span v-if="!g.length" class="fc-tqb__empty">empty</span>
</div>
<div class="fc-tqb__group-actions">
<TagPicker
placeholder="or…" class="fc-tqb__picker"
@pick="(t) => addToGroup(gi, t)"
/>
<v-btn
icon="mdi-delete-outline" variant="text" size="small"
aria-label="Remove this group" @click="removeGroup(gi)"
/>
</div>
</div>
</template>
<div class="fc-tqb__add">
<TagPicker
placeholder="Add a tag (new group)…" class="fc-tqb__picker"
@pick="addNewGroup"
/>
</div>
</div>
<v-divider class="my-4" />
<!-- exclude -->
<div class="fc-tqb__section">
<div class="fc-tqb__section-title">Exclude (must NOT have)</div>
<div class="fc-tqb__group-chips">
<v-chip
v-for="id in excludeIds" :key="id"
size="small" closable color="error" variant="tonal"
@click:close="removeExclude(id)"
>
<v-icon start size="x-small">mdi-minus</v-icon>{{ label(id) }}
</v-chip>
<span v-if="!excludeIds.length" class="fc-tqb__empty">none</span>
</div>
<div class="fc-tqb__add">
<TagPicker
placeholder="Exclude a tag…" class="fc-tqb__picker"
@pick="addExclude"
/>
</div>
</div>
</v-card-text>
<v-card-actions class="fc-tqb__actions">
<v-btn
variant="text" size="small" :disabled="!isDirty && isEmpty"
@click="clearAll"
>Clear</v-btn>
<v-spacer />
<v-btn variant="text" @click="$emit('close')">Cancel</v-btn>
<v-btn color="accent" variant="flat" rounded="pill" @click="apply">
Apply filter
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useTagStore } from '../../stores/tags.js'
import TagPicker from '../common/TagPicker.vue'
// One editor for the whole structured tag model. It works purely on "AND-of-OR
// groups + exclude": singleton includes (tag_ids) and OR-groups (tag_or) are
// unified into one `groups` list here, then split back apart on Apply so the
// URL stays compact (singletons serialize to tag_id, multi-tag groups to
// tag_or). Writes the SAME model the light chips edit — two editors, one model.
const props = defineProps({
include: { type: Array, default: () => [] }, // tag_ids
orGroups: { type: Array, default: () => [] }, // tag_or
exclude: { type: Array, default: () => [] }, // tag_exclude
labels: { type: Object, default: () => ({}) }, // id -> name (best effort)
})
const emit = defineEmits(['apply', 'close'])
const tagStore = useTagStore()
// Seed local draft from props (the dialog is recreated on each open, so a
// setup-time seed is the current filter every time).
const groups = ref([
...props.include.map((id) => [id]),
...props.orGroups.map((g) => [...g]),
])
const excludeIds = ref([...props.exclude])
// Names/kinds learned as tags are picked, layered over the labels passed in.
const nameMap = ref({ ...props.labels })
const kindMap = ref({})
function label (id) { return nameMap.value[id] || `#${id}` }
function kindColor (id) { return tagStore.colorFor(kindMap.value[id] || 'general') }
function _note (tag) {
nameMap.value = { ...nameMap.value, [tag.id]: tag.name }
kindMap.value = { ...kindMap.value, [tag.id]: tag.kind }
}
function _dropFromGroups (id) {
groups.value = groups.value
.map((g) => g.filter((t) => t !== id))
.filter((g) => g.length)
}
function addToGroup (gi, tag) {
_note(tag)
excludeIds.value = excludeIds.value.filter((t) => t !== tag.id) // can't be both
if (!groups.value[gi].includes(tag.id)) groups.value[gi].push(tag.id)
}
function addNewGroup (tag) {
_note(tag)
excludeIds.value = excludeIds.value.filter((t) => t !== tag.id)
groups.value.push([tag.id])
}
function removeFromGroup (gi, id) {
groups.value[gi] = groups.value[gi].filter((t) => t !== id)
if (!groups.value[gi].length) groups.value.splice(gi, 1)
}
function removeGroup (gi) { groups.value.splice(gi, 1) }
function addExclude (tag) {
_note(tag)
_dropFromGroups(tag.id) // a tag can't be both required and excluded
if (!excludeIds.value.includes(tag.id)) excludeIds.value.push(tag.id)
}
function removeExclude (id) {
excludeIds.value = excludeIds.value.filter((t) => t !== id)
}
function clearAll () { groups.value = []; excludeIds.value = [] }
const isEmpty = computed(() => !groups.value.length && !excludeIds.value.length)
const isDirty = computed(() =>
JSON.stringify(groups.value) !== JSON.stringify(props.orGroups.length || props.include.length
? [...props.include.map((id) => [id]), ...props.orGroups]
: []) ||
JSON.stringify(excludeIds.value) !== JSON.stringify(props.exclude),
)
function apply () {
const clean = groups.value.filter((g) => g.length)
emit('apply', {
tag_ids: clean.filter((g) => g.length === 1).map((g) => g[0]),
tag_or: clean.filter((g) => g.length > 1),
tag_exclude: [...excludeIds.value],
labels: nameMap.value, // so freshly-picked chips show their name on return
})
}
</script>
<style scoped>
.fc-tqb__head { display: flex; align-items: center; }
.fc-tqb__hint {
color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 16px;
}
.fc-tqb__section { margin-bottom: 4px; }
.fc-tqb__section-title {
font-size: 12px; font-weight: 600; letter-spacing: 0.04em;
text-transform: uppercase;
color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 8px;
}
.fc-tqb__and {
font-size: 11px; font-weight: 700; letter-spacing: 0.08em;
color: rgb(var(--v-theme-accent));
margin: 6px 0 6px 2px;
}
.fc-tqb__group {
display: flex; align-items: center; gap: 12px;
flex-wrap: wrap;
padding: 8px 10px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
background: rgba(var(--v-theme-on-surface), 0.03);
}
.fc-tqb__group-chips {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
flex: 1 1 220px; min-width: 0;
}
.fc-tqb__or {
font-size: 11px; font-style: italic;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-tqb__group-actions {
display: flex; align-items: center; gap: 4px;
}
.fc-tqb__picker { min-width: 180px; max-width: 240px; }
.fc-tqb__add { margin-top: 10px; max-width: 280px; }
.fc-tqb__empty {
font-size: 12px; font-style: italic;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-tqb__actions { padding: 8px 16px 16px; }
</style>