diff --git a/frontend/src/components/common/TagPicker.vue b/frontend/src/components/common/TagPicker.vue
new file mode 100644
index 0000000..8e96aa0
--- /dev/null
+++ b/frontend/src/components/common/TagPicker.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+ {{ item.raw.fandom_name ? `character · ${item.raw.fandom_name}` : item.raw.kind }}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/gallery/GalleryFilterBar.vue b/frontend/src/components/gallery/GalleryFilterBar.vue
index 39c86ac..e13949a 100644
--- a/frontend/src/components/gallery/GalleryFilterBar.vue
+++ b/frontend/src/components/gallery/GalleryFilterBar.vue
@@ -27,11 +27,34 @@
+
{{ store.tagLabels[id] || `#${id}` }}
+
+ mdi-minus{{ store.tagLabels[id] || `#${id}` }}
+
+ {{ orGroupCount }} OR-group{{ orGroupCount > 1 ? 's' : '' }}
+ Advanced{{ advancedCount ? ` (${advancedCount})` : '' }}
+
+
+
+
+
@@ -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
diff --git a/frontend/src/components/gallery/TagQueryBuilder.vue b/frontend/src/components/gallery/TagQueryBuilder.vue
new file mode 100644
index 0000000..f674112
--- /dev/null
+++ b/frontend/src/components/gallery/TagQueryBuilder.vue
@@ -0,0 +1,236 @@
+
+
+
+
+ Advanced tag filter
+
+
+
+
+
+
+ An image must match every group below, and a group
+ matches when the image carries any tag in it
+ (AND of ORs). Excluded tags are never shown.
+
+
+
+
+
Must match all of
+
+
+ No groups yet — add a tag below to start.
+
+
+
+ AND
+
+
+
+ or
+ {{ label(id) }}
+
+ empty
+
+
+ addToGroup(gi, t)"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Exclude (must NOT have)
+
+
+ mdi-minus{{ label(id) }}
+
+ none
+
+
+
+
+
+
+
+
+ Clear
+
+ Cancel
+
+ Apply filter
+
+
+
+
+
+
+
+
diff --git a/frontend/src/composables/useApi.js b/frontend/src/composables/useApi.js
index 1be0c31..76cb4f7 100644
--- a/frontend/src/composables/useApi.js
+++ b/frontend/src/composables/useApi.js
@@ -15,7 +15,16 @@ async function request(method, url, { body, params, signal } = {}) {
if (params) {
const search = new URLSearchParams()
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()
if (qs) fullUrl += (url.includes('?') ? '&' : '?') + qs
diff --git a/frontend/src/stores/gallery.js b/frontend/src/stores/gallery.js
index bad0d8b..a0599bf 100644
--- a/frontend/src/stores/gallery.js
+++ b/frontend/src/stores/gallery.js
@@ -24,6 +24,10 @@ export const useGalleryStore = defineStore('gallery', () => {
const filter = ref({
tag_ids: [], artist_id: null, media_type: 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.
platform: null, untagged: false, no_artist: false,
date_from: null, date_to: null,
@@ -102,6 +106,9 @@ export const useGalleryStore = defineStore('gallery', () => {
const f = filter.value
const params = { similar_to: f.similar_to, limit: 100 }
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.media_type) params.media = f.media_type
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 }
const p = {}
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.media_type) p.media = filter.value.media_type
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) {
filter.value = {
tag_ids: _parseIds(q.tag_id),
+ tag_or: _parseGroups(q.tag_or),
+ tag_exclude: _parseIds(q.tag_not),
artist_id: _toId(q.artist_id),
media_type: ['image', 'video'].includes(q.media) ? q.media : null,
sort: q.sort === 'oldest' ? 'oldest' : 'newest',
@@ -211,6 +223,14 @@ export const useGalleryStore = defineStore('gallery', () => {
if (!raw) return []
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
// 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 }
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
try {
const t = await api.get(`/api/tags/${id}`)
@@ -252,7 +279,10 @@ export const useGalleryStore = defineStore('gallery', () => {
// the exclusive post-detail view.
export function cloneFilter(f) {
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,
no_artist: f.no_artist, date_from: f.date_from, date_to: f.date_to,
similar_to: f.similar_to,
@@ -262,6 +292,9 @@ export function cloneFilter(f) {
export function filterToQuery(f) {
const q = {}
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.media_type) q.media = f.media_type
if (f.sort && f.sort !== 'newest') q.sort = f.sort
@@ -274,6 +307,15 @@ export function filterToQuery(f) {
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) {
// Merge sequential groups with the same (year, month) instead of duplicating.
const merged = [...existing]
diff --git a/frontend/test/gallery.spec.js b/frontend/test/gallery.spec.js
index 93ce5c6..ce533e3 100644
--- a/frontend/test/gallery.spec.js
+++ b/frontend/test/gallery.spec.js
@@ -144,6 +144,40 @@ describe('gallery store: composable filter', () => {
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 () => {
const s = useGalleryStore()
const urls = []
@@ -181,6 +215,26 @@ describe('filterToQuery / cloneFilter', () => {
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', () => {
const orig = {
tag_ids: [1], artist_id: 2, media_type: 'image', sort: 'oldest',