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:
@@ -1 +0,0 @@
|
||||
{"sessionId":"168e9de3-9f44-4fca-8fa6-09c87523c11e","pid":1470750,"procStart":"11499472","acquiredAt":1780543996001}
|
||||
@@ -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__/
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user