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 @@ + + + + + + + {{ iconFor(item.raw) }} + + + {{ item.raw.kind === 'artist' ? 'artist' + : (item.raw.fandom_name ? `character · ${item.raw.fandom_name}` : item.raw.kind) }} + + + + + + + {{ store.tagLabels[id] || `#${id}` }} + {{ store.artistLabel || `Artist #${store.filter.artist_id}` }} + + + + + setMedia(v === 'all' ? null : v)" + > + All + Images + Videos + + + + + Clear + + + + + + 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 @@ - - Tag: {{ store.filterTagName || `#${store.filter.tag_id}` }} - + @@ -31,10 +25,11 @@