feat(gallery): faceted refine panel UI (Phase 2 frontend)
CI / lint (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 22s
CI / intimp (push) Successful in 3m34s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m30s

Add a 'Refine ▾' toggle to the gallery filter bar that expands a full-width
GalleryFacetPanel below it, inside the same sticky hazey chrome. The panel
offers platform chips (with live counts + a 'No platform' unsourced bucket),
two count-badged curation-flag toggles (Untagged / No artist), and a from/to
date range bounded by the facet min/max.

Store gains the platform/untagged/no_artist/date_from/date_to filter params
(URL-mirrored, AND-composed) and a panel-gated, single-flighted loadFacets()
that fetches /api/gallery/facets scoped to the active filter. Shared
cloneFilter/filterToQuery helpers keep the bar and panel writing one URL
format. The panel auto-opens on deep-link when refine filters are present and
refetches counts (debounced) on every filter change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 07:06:51 -04:00
parent 9fe534139a
commit 1adc47f59c
5 changed files with 398 additions and 27 deletions
@@ -0,0 +1,53 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, afterEach } from 'vitest'
import GalleryFacetPanel from '../../src/components/gallery/GalleryFacetPanel.vue'
import { useGalleryStore } from '../../src/stores/gallery.js'
import { freshPinia, mountComponent } from '../support/mountComponent.js'
const FACETS = {
total: 5,
platforms: [
{ value: 'patreon', count: 3 },
{ value: null, count: 2 }, // unsourced → "No platform" bucket
],
untagged: 4,
no_artist: 1,
date_min: '2020-01-01T00:00:00+00:00',
date_max: '2026-06-01T00:00:00+00:00',
}
function stubFetchOk(body) {
globalThis.fetch = vi.fn(async () => ({
ok: true, status: 200, statusText: 'OK',
text: async () => JSON.stringify(body),
}))
}
describe('GalleryFacetPanel', () => {
afterEach(() => vi.restoreAllMocks())
it('renders platform options with counts and the curation flags', () => {
const pinia = freshPinia()
stubFetchOk(FACETS) // covers the onMounted loadFacets() call
const s = useGalleryStore()
s.facets = FACETS // seed so counts render synchronously
const w = mountComponent(GalleryFacetPanel, { pinia })
const t = w.text()
expect(t).toContain('patreon')
expect(t).toContain('3')
expect(t).toContain('No platform')
expect(t).toContain('Untagged')
expect(t).toContain('4')
expect(t).toContain('No artist')
})
it('shows an en-dash placeholder for a flag count before facets load', () => {
const pinia = freshPinia()
stubFetchOk(FACETS)
useGalleryStore().facets = null // not loaded yet
const w = mountComponent(GalleryFacetPanel, { pinia })
expect(w.text()).toContain('Untagged')
expect(w.text()).toContain('')
})
})
+81 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useGalleryStore } from '../src/stores/gallery.js'
import { cloneFilter, filterToQuery, useGalleryStore } from '../src/stores/gallery.js'
function stubFetch(handler) {
globalThis.fetch = vi.fn(async (url, init) => {
@@ -73,6 +73,55 @@ describe('gallery store: composable filter', () => {
expect(s.artistLabel).toBe('Kubo')
})
it('parses faceted refine params and loadMore forwards them', async () => {
const s = useGalleryStore()
const urls = []
stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
await s.applyFilterFromQuery({
platform: 'pixiv', untagged: '1', no_artist: '1',
date_from: '2024-01-01', date_to: '2024-12-31',
})
expect(s.filter.platform).toBe('pixiv')
expect(s.filter.untagged).toBe(true)
expect(s.filter.no_artist).toBe(true)
expect(s.filter.date_from).toBe('2024-01-01')
expect(s.filter.date_to).toBe('2024-12-31')
const scroll = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/scroll')))
expect(scroll).toContain('platform=pixiv')
expect(scroll).toContain('untagged=1')
expect(scroll).toContain('no_artist=1')
expect(scroll).toContain('date_from=2024-01-01')
expect(scroll).toContain('date_to=2024-12-31')
})
it('drops a malformed date in the query', async () => {
const s = useGalleryStore()
stubFetch(() => ({ status: 200, body: EMPTY }))
await s.applyFilterFromQuery({ date_from: 'garbage' })
expect(s.filter.date_from).toBe(null)
})
it('loadFacets fetches counts scoped to the active filter', async () => {
const s = useGalleryStore()
const urls = []
const FACETS = {
total: 4, platforms: [{ value: 'patreon', count: 2 }],
untagged: 1, no_artist: 0,
date_min: '2020-01-01T00:00:00+00:00', date_max: '2026-06-01T00:00:00+00:00',
}
stubFetch((url) => {
urls.push(url)
if (url.includes('/api/gallery/facets')) return { status: 200, body: FACETS }
return { status: 200, body: EMPTY }
})
await s.applyFilterFromQuery({ platform: 'patreon', untagged: '1' })
await s.loadFacets()
const facetsUrl = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/facets')))
expect(facetsUrl).toContain('platform=patreon')
expect(facetsUrl).toContain('untagged=1')
expect(s.facets.total).toBe(4)
})
it('loadInitial issues exactly one scroll request at the initial limit', async () => {
const s = useGalleryStore()
const urls = []
@@ -88,3 +137,34 @@ describe('gallery store: composable filter', () => {
expect(scrollCalls[0]).toContain('limit=50')
})
})
describe('filterToQuery / cloneFilter', () => {
it('serializes faceted params incl. platform sentinel and flags', () => {
const q = filterToQuery({
tag_ids: [3], artist_id: null, media_type: null, sort: 'newest',
platform: '__unsourced__', untagged: true, no_artist: false,
date_from: '2024-01-01', date_to: null,
})
expect(q.tag_id).toBe('3')
expect(q.platform).toBe('__unsourced__')
expect(q.untagged).toBe('1')
expect(q.no_artist).toBeUndefined()
expect(q.date_from).toBe('2024-01-01')
expect(q.date_to).toBeUndefined()
expect(q.sort).toBeUndefined() // newest is the default → omitted
})
it('cloneFilter copies refine fields and detaches tag_ids', () => {
const orig = {
tag_ids: [1], artist_id: 2, media_type: 'image', sort: 'oldest',
platform: 'patreon', untagged: true, no_artist: true,
date_from: '2024-01-01', date_to: '2024-02-01',
}
const c = cloneFilter(orig)
c.tag_ids.push(9)
expect(orig.tag_ids).toEqual([1]) // detached
expect(c.platform).toBe('patreon')
expect(c.untagged).toBe(true)
expect(c.date_to).toBe('2024-02-01')
})
})