From 6d630d13d68dd8e77b30fe6f433cf236b45ee75d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 23:57:11 -0400 Subject: [PATCH] feat(gallery): pinned filter bar (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude/scheduled_tasks.lock | 1 - .gitignore | 3 + .../components/gallery/GalleryFilterBar.vue | 202 ++++++++++++++++++ frontend/src/stores/gallery.js | 89 +++++--- frontend/src/views/GalleryView.vue | 43 +--- frontend/test/gallery.spec.js | 80 ++++--- 6 files changed, 320 insertions(+), 98 deletions(-) delete mode 100644 .claude/scheduled_tasks.lock create mode 100644 frontend/src/components/gallery/GalleryFilterBar.vue diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index c64db71..0000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"168e9de3-9f44-4fca-8fa6-09c87523c11e","pid":1470750,"procStart":"11499472","acquiredAt":1780543996001} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 06d2c93..10d4d4b 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,9 @@ Thumbs.db # Claude Code per-user local overrides (shared .claude/settings.json is OK to commit) .claude/settings.local.json +# Transient scheduler lock/state (committed by accident in 3f30327) +.claude/scheduled_tasks.lock +.claude/scheduled_tasks*.json # Alembic / DB scratch alembic/versions/__pycache__/ diff --git a/frontend/src/components/gallery/GalleryFilterBar.vue b/frontend/src/components/gallery/GalleryFilterBar.vue new file mode 100644 index 0000000..9db956a --- /dev/null +++ b/frontend/src/components/gallery/GalleryFilterBar.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/frontend/src/stores/gallery.js b/frontend/src/stores/gallery.js index 407b3a4..c84751d 100644 --- a/frontend/src/stores/gallery.js +++ b/frontend/src/stores/gallery.js @@ -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 "#". + // 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, } }) diff --git a/frontend/src/views/GalleryView.vue b/frontend/src/views/GalleryView.vue index ea318fe..a264a11 100644 --- a/frontend/src/views/GalleryView.vue +++ b/frontend/src/views/GalleryView.vue @@ -12,13 +12,7 @@