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
@@ -0,0 +1,202 @@
<template>
<div class="fc-filterbar">
<v-autocomplete
v-model="selected"
:items="searchItems"
:loading="searchLoading"
item-title="name" item-value="value"
no-filter clearable hide-details density="compact" variant="outlined"
placeholder="Filter by tag or artist…"
prepend-inner-icon="mdi-filter-variant"
class="fc-filterbar__search"
@update:search="onSearch"
@update:model-value="onPick"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps" :title="item.raw.name">
<template #prepend>
<v-icon size="small">{{ iconFor(item.raw) }}</v-icon>
</template>
<template #subtitle>
{{ item.raw.kind === 'artist' ? 'artist'
: (item.raw.fandom_name ? `character · ${item.raw.fandom_name}` : item.raw.kind) }}
</template>
</v-list-item>
</template>
</v-autocomplete>
<div class="fc-filterbar__chips">
<v-chip
v-for="id in store.filter.tag_ids" :key="`t${id}`"
size="small" closable :color="chipColor(id)" variant="tonal"
@click:close="removeTag(id)"
>{{ store.tagLabels[id] || `#${id}` }}</v-chip>
<v-chip
v-if="store.filter.artist_id"
size="small" closable color="accent" variant="tonal"
prepend-icon="mdi-account"
@click:close="clearArtist"
>{{ store.artistLabel || `Artist #${store.filter.artist_id}` }}</v-chip>
</div>
<v-spacer />
<v-btn-toggle
:model-value="store.filter.media_type ?? 'all'"
density="compact" mandatory variant="outlined" divided
@update:model-value="(v) => setMedia(v === 'all' ? null : v)"
>
<v-btn value="all" size="small">All</v-btn>
<v-btn value="image" size="small">Images</v-btn>
<v-btn value="video" size="small">Videos</v-btn>
</v-btn-toggle>
<v-select
:model-value="store.filter.sort"
:items="SORTS"
density="compact" hide-details variant="outlined"
class="fc-filterbar__sort"
@update:model-value="setSort"
/>
<v-btn
v-if="hasActiveFilters" variant="text" size="small"
prepend-icon="mdi-close" @click="clearAll"
>Clear</v-btn>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useApi } from '../../composables/useApi.js'
import { useGalleryStore } from '../../stores/gallery.js'
import { useTagStore } from '../../stores/tags.js'
const store = useGalleryStore()
const tagStore = useTagStore()
const api = useApi()
const router = useRouter()
const SORTS = [
{ title: 'Newest first', value: 'newest' },
{ title: 'Oldest first', value: 'oldest' },
]
const selected = ref(null)
const searchItems = ref([])
const searchLoading = ref(false)
let debounce = null
const hasActiveFilters = computed(() =>
store.filter.tag_ids.length > 0 ||
store.filter.artist_id != null ||
store.filter.media_type != null ||
store.filter.sort !== 'newest'
)
function iconFor(raw) {
if (raw.kind === 'artist') return 'mdi-account'
return { character: 'mdi-account-circle', fandom: 'mdi-book-open-page-variant',
series: 'mdi-bookshelf' }[raw.kind] || 'mdi-tag'
}
function chipColor(id) {
// Tag chips use the same per-kind palette as the rest of the app; we only
// know the kind from the autocomplete pick, so fall back to a neutral tone.
return tagStore.colorFor(pickedKind.value[id] || 'general')
}
const pickedKind = ref({})
function onSearch(q) {
if (debounce) clearTimeout(debounce)
if (!q || !q.trim()) { searchItems.value = []; return }
debounce = setTimeout(async () => {
searchLoading.value = true
try {
const [tags, artists] = await Promise.all([
api.get('/api/tags/autocomplete', { params: { q, limit: 10 } }),
api.get('/api/artists/autocomplete', { params: { q, limit: 10 } }),
])
searchItems.value = [
...(artists || []).map((a) => ({
kind: 'artist', id: a.id, name: a.name, value: `artist:${a.id}`,
})),
...(tags || []).map((t) => ({
kind: t.kind, id: t.id, name: t.name, value: `tag:${t.id}`,
fandom_name: t.fandom_name,
})),
]
} catch {
searchItems.value = []
} finally {
searchLoading.value = false
}
}, 250)
}
function onPick(value) {
const item = searchItems.value.find((i) => i.value === value)
selected.value = null
searchItems.value = []
if (!item) return
if (item.kind === 'artist') {
store.noteArtistLabel(item.name)
pushFilter((n) => { n.artist_id = item.id })
} else {
store.noteTagLabel(item.id, item.name)
pickedKind.value = { ...pickedKind.value, [item.id]: item.kind }
pushFilter((n) => { if (!n.tag_ids.includes(item.id)) n.tag_ids.push(item.id) })
}
}
function removeTag(id) {
pushFilter((n) => { n.tag_ids = n.tag_ids.filter((t) => t !== id) })
}
function clearArtist() {
store.noteArtistLabel(null)
pushFilter((n) => { n.artist_id = null })
}
function setMedia(m) { pushFilter((n) => { n.media_type = m }) }
function setSort(s) { pushFilter((n) => { n.sort = s }) }
function clearAll() { router.push({ name: 'gallery', query: {} }) }
// Single write path: clone the current filter, mutate, serialize to the URL.
// The route watcher in GalleryView applies it to the store and reloads.
function pushFilter(mutate) {
const f = store.filter
const n = {
tag_ids: [...f.tag_ids],
artist_id: f.artist_id,
media_type: f.media_type,
sort: f.sort,
}
mutate(n)
const q = {}
if (n.tag_ids.length) q.tag_id = n.tag_ids.join(',')
if (n.artist_id) q.artist_id = String(n.artist_id)
if (n.media_type) q.media = n.media_type
if (n.sort && n.sort !== 'newest') q.sort = n.sort
router.push({ name: 'gallery', query: q })
}
</script>
<style scoped>
/* Pinned under the 64px TopNav, matching the app's sticky v-tabs chrome
(SettingsView / ArtistHeader / SubscriptionsView). */
.fc-filterbar {
position: sticky;
top: 64px;
z-index: 4;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 8px 4px;
margin-bottom: 12px;
background: rgb(var(--v-theme-surface));
border-bottom: 1px solid rgb(var(--v-theme-surface-light));
}
.fc-filterbar__search { max-width: 320px; min-width: 200px; }
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.fc-filterbar__sort { max-width: 150px; }
</style>
+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,
}
})
+8 -35
View File
@@ -12,13 +12,7 @@
<div class="fc-gallery-layout">
<div class="fc-gallery-layout__main">
<PostInfoHeader />
<div v-if="store.filter.tag_id != null" class="fc-gallery-activefilter">
<v-chip
color="accent" variant="tonal" closable
prepend-icon="mdi-tag"
@click:close="clearTagFilter"
>Tag: {{ store.filterTagName || `#${store.filter.tag_id}` }}</v-chip>
</div>
<GalleryFilterBar v-if="store.filter.post_id == null" />
<EmptyState v-if="store.isEmpty" />
<GalleryGrid v-else @open="openImage" />
</div>
@@ -31,10 +25,11 @@
<script setup>
import { onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import { useGalleryStore } from '../stores/gallery.js'
import { useModalStore } from '../stores/modal.js'
import GalleryGrid from '../components/gallery/GalleryGrid.vue'
import GalleryFilterBar from '../components/gallery/GalleryFilterBar.vue'
import TimelineSidebar from '../components/gallery/TimelineSidebar.vue'
import EmptyState from '../components/gallery/EmptyState.vue'
import PostInfoHeader from '../components/gallery/PostInfoHeader.vue'
@@ -45,40 +40,19 @@ const store = useGalleryStore()
const modal = useModalStore()
const sel = useGallerySelectionStore()
const route = useRoute()
const router = useRouter()
onMounted(async () => {
const postId = parseInt(route.query.post_id, 10)
const tagId = parseInt(route.query.tag_id, 10)
if (!isNaN(postId)) store.setPostFilter(postId)
else if (!isNaN(tagId)) store.setTagFilter(tagId)
await store.loadInitial()
await store.loadTimeline()
})
// The URL query is the single source of truth for filters. Apply it on
// mount and on any query change (filter bar pushes, back button, deep-link).
onMounted(() => store.applyFilterFromQuery(route.query))
watch(() => route.query.tag_id, (q) => {
watch(() => route.query, (q) => {
sel.clear() // result set changed — selected ids are no longer valid
const tagId = parseInt(q, 10)
store.setTagFilter(isNaN(tagId) ? null : tagId)
})
watch(() => route.query.post_id, (q) => {
sel.clear() // result set changed — selected ids are no longer valid
const postId = parseInt(q, 10)
store.setPostFilter(isNaN(postId) ? null : postId)
store.applyFilterFromQuery(q)
})
function openImage(id) {
modal.open(id)
}
// Clear the active tag filter by dropping it from the URL; the route
// watcher above resets the store filter and reloads.
function clearTagFilter() {
const q = { ...route.query }
delete q.tag_id
router.push({ query: q })
}
</script>
<style scoped>
@@ -88,7 +62,6 @@ function clearTagFilter() {
align-items: flex-start;
}
.fc-gallery-layout__main { flex: 1; min-width: 0; }
.fc-gallery-activefilter { margin-bottom: 12px; }
.fc-gallery-layout__sidebar { flex-shrink: 0; }
@media (max-width: 900px) {
+45 -35
View File
@@ -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')
})