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>
|
||||||
@@ -27,11 +27,34 @@
|
|||||||
</v-autocomplete>
|
</v-autocomplete>
|
||||||
|
|
||||||
<div class="fc-filterbar__chips">
|
<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-chip
|
||||||
v-for="id in store.filter.tag_ids" :key="`t${id}`"
|
v-for="id in store.filter.tag_ids" :key="`t${id}`"
|
||||||
size="small" closable :color="chipColor(id)" variant="tonal"
|
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)"
|
@click:close="removeTag(id)"
|
||||||
>{{ store.tagLabels[id] || `#${id}` }}</v-chip>
|
>{{ 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-chip
|
||||||
v-if="store.filter.artist_id"
|
v-if="store.filter.artist_id"
|
||||||
size="small" closable color="accent" variant="tonal"
|
size="small" closable color="accent" variant="tonal"
|
||||||
@@ -68,6 +91,13 @@
|
|||||||
@update:model-value="setSort"
|
@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
|
<v-btn
|
||||||
:color="refineOpen ? 'accent' : undefined"
|
:color="refineOpen ? 'accent' : undefined"
|
||||||
:variant="refineOpen || hasRefineFilters ? 'tonal' : 'text'"
|
:variant="refineOpen || hasRefineFilters ? 'tonal' : 'text'"
|
||||||
@@ -83,6 +113,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GalleryFacetPanel v-if="refineOpen" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -93,6 +135,7 @@ import { useApi } from '../../composables/useApi.js'
|
|||||||
import { cloneFilter, filterToQuery, useGalleryStore } from '../../stores/gallery.js'
|
import { cloneFilter, filterToQuery, useGalleryStore } from '../../stores/gallery.js'
|
||||||
import { useTagStore } from '../../stores/tags.js'
|
import { useTagStore } from '../../stores/tags.js'
|
||||||
import GalleryFacetPanel from './GalleryFacetPanel.vue'
|
import GalleryFacetPanel from './GalleryFacetPanel.vue'
|
||||||
|
import TagQueryBuilder from './TagQueryBuilder.vue'
|
||||||
|
|
||||||
const store = useGalleryStore()
|
const store = useGalleryStore()
|
||||||
const tagStore = useTagStore()
|
const tagStore = useTagStore()
|
||||||
@@ -117,8 +160,19 @@ const refineCount = computed(() => {
|
|||||||
})
|
})
|
||||||
const hasRefineFilters = computed(() => refineCount.value > 0)
|
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(() =>
|
const hasActiveFilters = computed(() =>
|
||||||
store.filter.tag_ids.length > 0 ||
|
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.artist_id != null ||
|
||||||
store.filter.media_type != null ||
|
store.filter.media_type != null ||
|
||||||
store.filter.sort !== 'newest' ||
|
store.filter.sort !== 'newest' ||
|
||||||
@@ -195,6 +249,34 @@ function onPick(value) {
|
|||||||
function removeTag(id) {
|
function removeTag(id) {
|
||||||
pushFilter((n) => { n.tag_ids = n.tag_ids.filter((t) => t !== 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() {
|
function clearArtist() {
|
||||||
store.noteArtistLabel(null)
|
store.noteArtistLabel(null)
|
||||||
pushFilter((n) => { n.artist_id = 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__search { max-width: 320px; min-width: 200px; }
|
||||||
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
.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; }
|
.fc-filterbar__sort { max-width: 150px; }
|
||||||
|
|
||||||
/* Phones: the search's 200px min-width jams the wrapping bar. Give search its
|
/* 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>
|
||||||
@@ -15,7 +15,16 @@ async function request(method, url, { body, params, signal } = {}) {
|
|||||||
if (params) {
|
if (params) {
|
||||||
const search = new URLSearchParams()
|
const search = new URLSearchParams()
|
||||||
for (const [k, v] of Object.entries(params)) {
|
for (const [k, v] of Object.entries(params)) {
|
||||||
if (v !== undefined && v !== null) search.set(k, String(v))
|
if (v === undefined || v === null) continue
|
||||||
|
// Array values serialize as a REPEATED key (k=a&k=b) — e.g. the
|
||||||
|
// gallery's tag_or OR-groups, read server-side via request.args.getlist.
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
for (const item of v) {
|
||||||
|
if (item !== undefined && item !== null) search.append(k, String(item))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
search.set(k, String(v))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const qs = search.toString()
|
const qs = search.toString()
|
||||||
if (qs) fullUrl += (url.includes('?') ? '&' : '?') + qs
|
if (qs) fullUrl += (url.includes('?') ? '&' : '?') + qs
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
const filter = ref({
|
const filter = ref({
|
||||||
tag_ids: [], artist_id: null, media_type: null,
|
tag_ids: [], artist_id: null, media_type: null,
|
||||||
sort: 'newest', post_id: null,
|
sort: 'newest', post_id: null,
|
||||||
|
// #6 structured tag filter (AND-of-OR + exclude). tag_ids are the AND
|
||||||
|
// "include" singletons (light editor + back-compat); tag_or is a list of
|
||||||
|
// OR-groups (each group ORs, groups AND); tag_exclude is the NOT set.
|
||||||
|
tag_or: [], tag_exclude: [],
|
||||||
// Phase-2 faceted refine params.
|
// Phase-2 faceted refine params.
|
||||||
platform: null, untagged: false, no_artist: false,
|
platform: null, untagged: false, no_artist: false,
|
||||||
date_from: null, date_to: null,
|
date_from: null, date_to: null,
|
||||||
@@ -102,6 +106,9 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
const f = filter.value
|
const f = filter.value
|
||||||
const params = { similar_to: f.similar_to, limit: 100 }
|
const params = { similar_to: f.similar_to, limit: 100 }
|
||||||
if (f.tag_ids.length) params.tag_id = f.tag_ids.join(',')
|
if (f.tag_ids.length) params.tag_id = f.tag_ids.join(',')
|
||||||
|
const orParam = _orGroupsParam(f.tag_or)
|
||||||
|
if (orParam.length) params.tag_or = orParam
|
||||||
|
if (f.tag_exclude.length) params.tag_not = f.tag_exclude.join(',')
|
||||||
if (f.artist_id) params.artist_id = f.artist_id
|
if (f.artist_id) params.artist_id = f.artist_id
|
||||||
if (f.media_type) params.media = f.media_type
|
if (f.media_type) params.media = f.media_type
|
||||||
if (f.platform) params.platform = f.platform
|
if (f.platform) params.platform = f.platform
|
||||||
@@ -142,6 +149,9 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
if (filter.value.post_id) return { post_id: filter.value.post_id }
|
if (filter.value.post_id) return { post_id: filter.value.post_id }
|
||||||
const p = {}
|
const p = {}
|
||||||
if (filter.value.tag_ids.length) p.tag_id = filter.value.tag_ids.join(',')
|
if (filter.value.tag_ids.length) p.tag_id = filter.value.tag_ids.join(',')
|
||||||
|
const orParam = _orGroupsParam(filter.value.tag_or)
|
||||||
|
if (orParam.length) p.tag_or = orParam // array → repeated tag_or= key
|
||||||
|
if (filter.value.tag_exclude.length) p.tag_not = filter.value.tag_exclude.join(',')
|
||||||
if (filter.value.artist_id) p.artist_id = filter.value.artist_id
|
if (filter.value.artist_id) p.artist_id = filter.value.artist_id
|
||||||
if (filter.value.media_type) p.media = filter.value.media_type
|
if (filter.value.media_type) p.media = filter.value.media_type
|
||||||
if (filter.value.sort && filter.value.sort !== 'newest') p.sort = filter.value.sort
|
if (filter.value.sort && filter.value.sort !== 'newest') p.sort = filter.value.sort
|
||||||
@@ -177,6 +187,8 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
async function applyFilterFromQuery(q) {
|
async function applyFilterFromQuery(q) {
|
||||||
filter.value = {
|
filter.value = {
|
||||||
tag_ids: _parseIds(q.tag_id),
|
tag_ids: _parseIds(q.tag_id),
|
||||||
|
tag_or: _parseGroups(q.tag_or),
|
||||||
|
tag_exclude: _parseIds(q.tag_not),
|
||||||
artist_id: _toId(q.artist_id),
|
artist_id: _toId(q.artist_id),
|
||||||
media_type: ['image', 'video'].includes(q.media) ? q.media : null,
|
media_type: ['image', 'video'].includes(q.media) ? q.media : null,
|
||||||
sort: q.sort === 'oldest' ? 'oldest' : 'newest',
|
sort: q.sort === 'oldest' ? 'oldest' : 'newest',
|
||||||
@@ -211,6 +223,14 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
if (!raw) return []
|
if (!raw) return []
|
||||||
return String(raw).split(',').map(Number).filter((n) => Number.isInteger(n) && n > 0)
|
return String(raw).split(',').map(Number).filter((n) => Number.isInteger(n) && n > 0)
|
||||||
}
|
}
|
||||||
|
// tag_or arrives from the URL as a string (one group) or string[] (many);
|
||||||
|
// each value is a comma-separated OR-group. Normalize to ids-of-ids, dropping
|
||||||
|
// empties so a stray `tag_or=` can't become a match-nothing group.
|
||||||
|
function _parseGroups(raw) {
|
||||||
|
if (raw == null) return []
|
||||||
|
const values = Array.isArray(raw) ? raw : [raw]
|
||||||
|
return values.map(_parseIds).filter((g) => g.length)
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-seed a label so a freshly-picked chip shows its name without a
|
// Pre-seed a label so a freshly-picked chip shows its name without a
|
||||||
// round-trip; the bar calls these before pushing the new URL.
|
// round-trip; the bar calls these before pushing the new URL.
|
||||||
@@ -218,7 +238,14 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
function noteArtistLabel(name) { artistLabel.value = name || null }
|
function noteArtistLabel(name) { artistLabel.value = name || null }
|
||||||
|
|
||||||
async function _resolveLabels() {
|
async function _resolveLabels() {
|
||||||
for (const id of filter.value.tag_ids) {
|
// Every tag id referenced anywhere in the structured filter needs a chip
|
||||||
|
// label — includes, every OR-group member, and excludes.
|
||||||
|
const allTagIds = new Set([
|
||||||
|
...filter.value.tag_ids,
|
||||||
|
...filter.value.tag_or.flat(),
|
||||||
|
...filter.value.tag_exclude,
|
||||||
|
])
|
||||||
|
for (const id of allTagIds) {
|
||||||
if (tagLabels.value[id]) continue
|
if (tagLabels.value[id]) continue
|
||||||
try {
|
try {
|
||||||
const t = await api.get(`/api/tags/${id}`)
|
const t = await api.get(`/api/tags/${id}`)
|
||||||
@@ -252,7 +279,10 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
// the exclusive post-detail view.
|
// the exclusive post-detail view.
|
||||||
export function cloneFilter(f) {
|
export function cloneFilter(f) {
|
||||||
return {
|
return {
|
||||||
tag_ids: [...f.tag_ids], artist_id: f.artist_id, media_type: f.media_type,
|
tag_ids: [...f.tag_ids],
|
||||||
|
tag_or: (f.tag_or || []).map((g) => [...g]), // deep-clone the groups
|
||||||
|
tag_exclude: [...(f.tag_exclude || [])],
|
||||||
|
artist_id: f.artist_id, media_type: f.media_type,
|
||||||
sort: f.sort, platform: f.platform, untagged: f.untagged,
|
sort: f.sort, platform: f.platform, untagged: f.untagged,
|
||||||
no_artist: f.no_artist, date_from: f.date_from, date_to: f.date_to,
|
no_artist: f.no_artist, date_from: f.date_from, date_to: f.date_to,
|
||||||
similar_to: f.similar_to,
|
similar_to: f.similar_to,
|
||||||
@@ -262,6 +292,9 @@ export function cloneFilter(f) {
|
|||||||
export function filterToQuery(f) {
|
export function filterToQuery(f) {
|
||||||
const q = {}
|
const q = {}
|
||||||
if (f.tag_ids?.length) q.tag_id = f.tag_ids.join(',')
|
if (f.tag_ids?.length) q.tag_id = f.tag_ids.join(',')
|
||||||
|
const orParam = _orGroupsParam(f.tag_or)
|
||||||
|
if (orParam.length) q.tag_or = orParam // array → repeated query key
|
||||||
|
if (f.tag_exclude?.length) q.tag_not = f.tag_exclude.join(',')
|
||||||
if (f.artist_id) q.artist_id = String(f.artist_id)
|
if (f.artist_id) q.artist_id = String(f.artist_id)
|
||||||
if (f.media_type) q.media = f.media_type
|
if (f.media_type) q.media = f.media_type
|
||||||
if (f.sort && f.sort !== 'newest') q.sort = f.sort
|
if (f.sort && f.sort !== 'newest') q.sort = f.sort
|
||||||
@@ -274,6 +307,15 @@ export function filterToQuery(f) {
|
|||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Serialize OR-groups to repeated-param form: [[1,2],[3]] → ['1,2','3'],
|
||||||
|
// dropping empty groups. Shared by the store's activeFilterParam and the
|
||||||
|
// router-facing filterToQuery so both write tag_or the same way.
|
||||||
|
function _orGroupsParam(groups) {
|
||||||
|
return (groups || [])
|
||||||
|
.map((g) => (g || []).join(','))
|
||||||
|
.filter((s) => s.length)
|
||||||
|
}
|
||||||
|
|
||||||
function mergeGroups(existing, incoming) {
|
function mergeGroups(existing, incoming) {
|
||||||
// Merge sequential groups with the same (year, month) instead of duplicating.
|
// Merge sequential groups with the same (year, month) instead of duplicating.
|
||||||
const merged = [...existing]
|
const merged = [...existing]
|
||||||
|
|||||||
@@ -144,6 +144,40 @@ describe('gallery store: composable filter', () => {
|
|||||||
expect(urls.some((u) => u.includes('/api/gallery/timeline'))).toBe(false)
|
expect(urls.some((u) => u.includes('/api/gallery/timeline'))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('parses tag_or OR-groups + tag_not and forwards them (incl. repeated tag_or)', async () => {
|
||||||
|
const s = useGalleryStore()
|
||||||
|
const urls = []
|
||||||
|
stubFetch((url) => {
|
||||||
|
urls.push(url)
|
||||||
|
if (url.includes('/api/tags/')) {
|
||||||
|
return { status: 200, body: { id: 1, name: 'X', kind: 'general' } }
|
||||||
|
}
|
||||||
|
return { status: 200, body: EMPTY }
|
||||||
|
})
|
||||||
|
// tag_or arrives as an array (two groups); tag_not as a csv exclude list.
|
||||||
|
await s.applyFilterFromQuery({ tag_or: ['1,2', '3'], tag_not: '4,5' })
|
||||||
|
expect(s.filter.tag_or).toEqual([[1, 2], [3]])
|
||||||
|
expect(s.filter.tag_exclude).toEqual([4, 5])
|
||||||
|
const scroll = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/scroll')))
|
||||||
|
expect(scroll).toContain('tag_or=1,2') // first OR-group
|
||||||
|
expect(scroll).toContain('tag_or=3') // second OR-group (repeated key)
|
||||||
|
expect(scroll).toContain('tag_not=4,5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes a single-string tag_or and drops empty groups', async () => {
|
||||||
|
const s = useGalleryStore()
|
||||||
|
stubFetch((url) => {
|
||||||
|
if (url.includes('/api/tags/')) {
|
||||||
|
return { status: 200, body: { id: 1, name: 'X', kind: 'general' } }
|
||||||
|
}
|
||||||
|
return { status: 200, body: EMPTY }
|
||||||
|
})
|
||||||
|
await s.applyFilterFromQuery({ tag_or: '7,8' }) // single string → one group
|
||||||
|
expect(s.filter.tag_or).toEqual([[7, 8]])
|
||||||
|
await s.applyFilterFromQuery({ tag_or: ['', '9'] }) // empty group dropped
|
||||||
|
expect(s.filter.tag_or).toEqual([[9]])
|
||||||
|
})
|
||||||
|
|
||||||
it('loadInitial issues exactly one scroll request at the initial limit', async () => {
|
it('loadInitial issues exactly one scroll request at the initial limit', async () => {
|
||||||
const s = useGalleryStore()
|
const s = useGalleryStore()
|
||||||
const urls = []
|
const urls = []
|
||||||
@@ -181,6 +215,26 @@ describe('filterToQuery / cloneFilter', () => {
|
|||||||
expect(filterToQuery({ tag_ids: [], similar_to: null }).similar_to).toBeUndefined()
|
expect(filterToQuery({ tag_ids: [], similar_to: null }).similar_to).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('serializes tag_or OR-groups (repeated key) + tag_not, dropping empties', () => {
|
||||||
|
const q = filterToQuery({
|
||||||
|
tag_ids: [], tag_or: [[1, 2], [3], []], tag_exclude: [4, 5],
|
||||||
|
})
|
||||||
|
expect(q.tag_or).toEqual(['1,2', '3']) // empty group dropped → array
|
||||||
|
expect(q.tag_not).toBe('4,5')
|
||||||
|
const empty = filterToQuery({ tag_ids: [], tag_or: [], tag_exclude: [] })
|
||||||
|
expect(empty.tag_or).toBeUndefined()
|
||||||
|
expect(empty.tag_not).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cloneFilter deep-clones OR-groups + exclude', () => {
|
||||||
|
const orig = { tag_ids: [], tag_or: [[1, 2]], tag_exclude: [3] }
|
||||||
|
const c = cloneFilter(orig)
|
||||||
|
c.tag_or[0].push(9)
|
||||||
|
c.tag_exclude.push(8)
|
||||||
|
expect(orig.tag_or).toEqual([[1, 2]]) // group detached
|
||||||
|
expect(orig.tag_exclude).toEqual([3]) // exclude detached
|
||||||
|
})
|
||||||
|
|
||||||
it('cloneFilter copies refine fields and detaches tag_ids', () => {
|
it('cloneFilter copies refine fields and detaches tag_ids', () => {
|
||||||
const orig = {
|
const orig = {
|
||||||
tag_ids: [1], artist_id: 2, media_type: 'image', sort: 'oldest',
|
tag_ids: [1], artist_id: 2, media_type: 'image', sort: 'oldest',
|
||||||
|
|||||||
Reference in New Issue
Block a user