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 @@ + + + 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 @@ + + + + + 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',