feat(gallery): pinned filter bar (Phase 1)
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 29s
CI / intimp (push) Successful in 3m29s
CI / intapi (push) Successful in 7m24s
CI / intcore (push) Successful in 8m4s

Gallery now has in-view filtering, styled like the app's sticky v-tabs
chrome (pinned at top:64px under TopNav).

- GalleryFilterBar: combined tag+artist autocomplete (searches
  /api/tags + /api/artists), closable filter chips (multi-tag AND),
  media toggle (All/Images/Videos), Newest/Oldest sort, Clear. Writes all
  state to the URL via router.push.
- gallery store: filter is now { tag_ids, artist_id, media_type, sort,
  post_id }; applyFilterFromQuery makes the URL the single source of truth
  (deep-linkable, back-button works); chip labels resolved by id or
  pre-noted on pick. Replaces the standalone tag chip + setTag/PostFilter.
- GalleryView: renders the bar (hidden in post-detail), syncs route.query
  → store on mount + every query change.

Also untracks the transient .claude/scheduled_tasks.lock committed in
3f30327 and gitignores it.

Tests: store parses query → composable scroll params, post_id exclusivity,
newest-sort omitted, label pre-seed, single initial fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 23:57:11 -04:00
parent 3f30327fa5
commit 6d630d13d6
6 changed files with 320 additions and 98 deletions
+45 -35
View File
@@ -9,58 +9,68 @@ function stubFetch(handler) {
ok: status >= 200 && status < 300,
status,
statusText: String(status),
text: async () => (body == null ? '' : JSON.stringify(body))
text: async () => (body == null ? '' : JSON.stringify(body)),
}
})
}
const EMPTY = { images: [], date_groups: [], next_cursor: null }
describe('gallery store: tag/post filter exclusivity', () => {
describe('gallery store: composable filter', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.restoreAllMocks())
it('setPostFilter sets post_id and clears tag_id', () => {
it('applyFilterFromQuery parses the query and loadMore sends composable params', async () => {
const s = useGalleryStore()
stubFetch(() => ({ status: 200, body: EMPTY }))
s.setTagFilter(3)
expect(s.filter.tag_id).toBe(3)
s.setPostFilter(7)
expect(s.filter.post_id).toBe(7)
expect(s.filter.tag_id).toBe(null)
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 }
})
await s.applyFilterFromQuery({ tag_id: '1,2', artist_id: '5', media: 'video', sort: 'oldest' })
expect(s.filter.tag_ids).toEqual([1, 2])
expect(s.filter.artist_id).toBe(5)
expect(s.filter.media_type).toBe('video')
expect(s.filter.sort).toBe('oldest')
const scroll = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/scroll')))
expect(scroll).toContain('tag_id=1,2')
expect(scroll).toContain('artist_id=5')
expect(scroll).toContain('media=video')
expect(scroll).toContain('sort=oldest')
})
it('setTagFilter clears post_id', () => {
const s = useGalleryStore()
stubFetch(() => ({ status: 200, body: EMPTY }))
s.setPostFilter(7)
s.setTagFilter(3)
expect(s.filter.tag_id).toBe(3)
expect(s.filter.post_id).toBe(null)
})
it('loadMore sends exactly the active filter param', async () => {
it('omits sort=newest and sends no filter params when empty', async () => {
const s = useGalleryStore()
const urls = []
stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
s.setPostFilter(7)
await s.loadMore()
const scrollCall = urls.filter(u => u.includes('/api/gallery/scroll')).pop()
expect(scrollCall).toContain('post_id=7')
expect(scrollCall).not.toContain('tag_id=')
await s.applyFilterFromQuery({})
const scroll = urls.find((u) => u.includes('/api/gallery/scroll'))
expect(scroll).not.toContain('sort=')
expect(scroll).not.toContain('tag_id=')
expect(scroll).not.toContain('media=')
})
it('setTagFilter resolves the tag name for the filter chip', async () => {
it('treats post_id as the exclusive filter param', async () => {
const s = useGalleryStore()
stubFetch((url) => {
if (url.includes('/api/tags/7')) {
return { status: 200, body: { id: 7, name: 'Asuka', kind: 'character' } }
}
return { status: 200, body: EMPTY } // scroll + timeline
})
s.setTagFilter(7)
expect(s.filter.tag_id).toBe(7)
await vi.waitFor(() => expect(s.filterTagName).toBe('Asuka'))
const urls = []
stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
await s.applyFilterFromQuery({ post_id: '7', tag_id: '1' })
expect(s.filter.post_id).toBe(7)
const scroll = urls.find((u) => u.includes('/api/gallery/scroll'))
expect(scroll).toContain('post_id=7')
expect(scroll).not.toContain('tag_id=')
})
it('noteTagLabel and noteArtistLabel pre-seed chip labels', () => {
const s = useGalleryStore()
s.noteTagLabel(9, 'Rukia')
expect(s.tagLabels[9]).toBe('Rukia')
s.noteArtistLabel('Kubo')
expect(s.artistLabel).toBe('Kubo')
})
it('loadInitial issues exactly one scroll request at the initial limit', async () => {
@@ -73,7 +83,7 @@ describe('gallery store: tag/post filter exclusivity', () => {
return { status: 200, body: { images: [], date_groups: [], next_cursor: 'c1' } }
})
await s.loadInitial()
const scrollCalls = urls.filter(u => u.includes('/api/gallery/scroll'))
const scrollCalls = urls.filter((u) => u.includes('/api/gallery/scroll'))
expect(scrollCalls).toHaveLength(1)
expect(scrollCalls[0]).toContain('limit=50')
})