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
+62 -27
View File
@@ -21,10 +21,14 @@ export const useGalleryStore = defineStore('gallery', () => {
const nextCursor = ref(null)
const loading = ref(false)
const error = ref(null)
const filter = ref({ tag_id: null, post_id: null })
// Display name for the active tag filter, so the gallery can label a
// "Tag: X ✕" chip instead of leaving the filter invisible/unclearable.
const filterTagName = ref(null)
const filter = ref({
tag_ids: [], artist_id: null, media_type: null,
sort: 'newest', post_id: null,
})
// Display names for the active filter chips — resolved by id on deep-link
// and pre-noted by the filter bar when a user picks from autocomplete.
const tagLabels = ref({}) // tagId -> name
const artistLabel = ref(null)
const timelineBuckets = ref([])
const timelineLoading = ref(false)
@@ -89,33 +93,63 @@ export const useGalleryStore = defineStore('gallery', () => {
}
function activeFilterParam() {
if (filter.value.tag_id) return { tag_id: filter.value.tag_id }
// post_id is the exclusive post-detail view.
if (filter.value.post_id) return { post_id: filter.value.post_id }
return {}
const p = {}
if (filter.value.tag_ids.length) p.tag_id = filter.value.tag_ids.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
return p
}
function setTagFilter(tagId) {
filter.value.tag_id = tagId
filter.value.post_id = null
filterTagName.value = null
if (tagId) _resolveTagName(tagId) // fire-and-forget; chip label only
loadInitial()
loadTimeline()
}
async function _resolveTagName(tagId) {
try {
filterTagName.value = (await api.get(`/api/tags/${tagId}`)).name
} catch {
// Leave null — the chip falls back to "#<id>".
// URL is the source of truth for filters. GalleryView calls this on mount
// and on every route-query change; the filter bar mutates the URL
// (router.push) rather than the store directly, so deep-links, the back
// button, and bar actions all funnel through one path.
async function applyFilterFromQuery(q) {
filter.value = {
tag_ids: _parseIds(q.tag_id),
artist_id: _toId(q.artist_id),
media_type: ['image', 'video'].includes(q.media) ? q.media : null,
sort: q.sort === 'oldest' ? 'oldest' : 'newest',
post_id: _toId(q.post_id),
}
await loadInitial()
await loadTimeline()
_resolveLabels()
}
function setPostFilter(postId) {
filter.value.post_id = postId
filter.value.tag_id = null
loadInitial()
loadTimeline()
function _toId(v) {
const n = Number(v)
return Number.isInteger(n) && n > 0 ? n : null
}
function _parseIds(raw) {
if (!raw) return []
return String(raw).split(',').map(Number).filter((n) => Number.isInteger(n) && n > 0)
}
// 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.
function noteTagLabel(id, name) { tagLabels.value = { ...tagLabels.value, [id]: name } }
function noteArtistLabel(name) { artistLabel.value = name || null }
async function _resolveLabels() {
for (const id of filter.value.tag_ids) {
if (tagLabels.value[id]) continue
try {
const t = await api.get(`/api/tags/${id}`)
tagLabels.value = { ...tagLabels.value, [id]: t.name }
} catch { /* chip falls back to #id */ }
}
if (filter.value.artist_id && !artistLabel.value) {
// The filtered set is this artist — derive the chip label from a tile.
const hit = images.value.find(
(i) => i.artist && i.artist.id === filter.value.artist_id
)
artistLabel.value = hit?.artist?.name || null
}
if (!filter.value.artist_id) artistLabel.value = null
}
const hasMore = computed(() => nextCursor.value !== null)
@@ -123,8 +157,9 @@ export const useGalleryStore = defineStore('gallery', () => {
return {
images, dateGroups, hasMore, isEmpty, loading, error,
filter, filterTagName, timelineBuckets, timelineLoading,
loadInitial, loadMore, loadTimeline, jumpTo, setTagFilter, setPostFilter
filter, tagLabels, artistLabel, timelineBuckets, timelineLoading,
loadInitial, loadMore, loadTimeline, jumpTo,
applyFilterFromQuery, noteTagLabel, noteArtistLabel,
}
})