feat(gallery): pinned filter bar (Phase 1)
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:
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user