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:
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user