feat(gallery): faceted refine panel UI (Phase 2 frontend)
Add a 'Refine ▾' toggle to the gallery filter bar that expands a full-width GalleryFacetPanel below it, inside the same sticky hazey chrome. The panel offers platform chips (with live counts + a 'No platform' unsourced bucket), two count-badged curation-flag toggles (Untagged / No artist), and a from/to date range bounded by the facet min/max. Store gains the platform/untagged/no_artist/date_from/date_to filter params (URL-mirrored, AND-composed) and a panel-gated, single-flighted loadFacets() that fetches /api/gallery/facets scoped to the active filter. Shared cloneFilter/filterToQuery helpers keep the bar and panel writing one URL format. The panel auto-opens on deep-link when refine filters are present and refetches counts (debounced) on every filter change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="fc-facets">
|
||||
<v-progress-linear
|
||||
v-if="store.facetsLoading" indeterminate color="accent"
|
||||
class="fc-facets__bar" height="2"
|
||||
/>
|
||||
|
||||
<div class="fc-facets__group">
|
||||
<span class="fc-facets__label">Platform</span>
|
||||
<div class="fc-facets__chips">
|
||||
<v-chip
|
||||
v-for="p in platformOptions" :key="p.key"
|
||||
size="small" label
|
||||
:variant="p.key === store.filter.platform ? 'flat' : 'tonal'"
|
||||
:color="p.key === store.filter.platform ? 'accent' : undefined"
|
||||
@click="selectPlatform(p.key)"
|
||||
>{{ p.label }}<span class="fc-facets__count">{{ p.count }}</span></v-chip>
|
||||
<span v-if="!platformOptions.length" class="fc-facets__empty">none</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fc-facets__group">
|
||||
<span class="fc-facets__label">Curation</span>
|
||||
<div class="fc-facets__chips">
|
||||
<v-chip
|
||||
size="small" label
|
||||
:variant="store.filter.untagged ? 'flat' : 'tonal'"
|
||||
:color="store.filter.untagged ? 'accent' : undefined"
|
||||
@click="toggleFlag('untagged')"
|
||||
>Untagged<span class="fc-facets__count">{{ facetCount('untagged') }}</span></v-chip>
|
||||
<v-chip
|
||||
size="small" label
|
||||
:variant="store.filter.no_artist ? 'flat' : 'tonal'"
|
||||
:color="store.filter.no_artist ? 'accent' : undefined"
|
||||
@click="toggleFlag('no_artist')"
|
||||
>No artist<span class="fc-facets__count">{{ facetCount('no_artist') }}</span></v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fc-facets__group">
|
||||
<span class="fc-facets__label">Date</span>
|
||||
<v-text-field
|
||||
type="date" density="compact" hide-details variant="outlined"
|
||||
:model-value="store.filter.date_from" :min="dateMin" :max="dateMax"
|
||||
class="fc-facets__date"
|
||||
@update:model-value="setDate('date_from', $event)"
|
||||
/>
|
||||
<span class="fc-facets__dash">–</span>
|
||||
<v-text-field
|
||||
type="date" density="compact" hide-details variant="outlined"
|
||||
:model-value="store.filter.date_to" :min="dateMin" :max="dateMax"
|
||||
class="fc-facets__date"
|
||||
@update:model-value="setDate('date_to', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { cloneFilter, filterToQuery, useGalleryStore } from '../../stores/gallery.js'
|
||||
|
||||
// Mirrors backend gallery_service.UNSOURCED_PLATFORM — the sentinel that
|
||||
// selects filesystem-imported content (the null/"no platform" bucket).
|
||||
const UNSOURCED = '__unsourced__'
|
||||
|
||||
const store = useGalleryStore()
|
||||
const router = useRouter()
|
||||
|
||||
const platformOptions = computed(() =>
|
||||
(store.facets?.platforms || []).map((p) => ({
|
||||
key: p.value === null ? UNSOURCED : p.value,
|
||||
label: p.value === null ? 'No platform' : p.value,
|
||||
count: p.count,
|
||||
}))
|
||||
)
|
||||
|
||||
function facetCount(flag) {
|
||||
const v = store.facets?.[flag]
|
||||
return v == null ? '–' : v
|
||||
}
|
||||
|
||||
const dateMin = computed(() => (store.facets?.date_min || '').slice(0, 10) || undefined)
|
||||
const dateMax = computed(() => (store.facets?.date_max || '').slice(0, 10) || undefined)
|
||||
|
||||
// Single write path, shared format with the bar: clone → patch → URL. The
|
||||
// route watcher in GalleryView reloads the store (and our filter-watch below
|
||||
// refetches the facet counts for the new scope).
|
||||
function pushPatch(patch) {
|
||||
const n = cloneFilter(store.filter)
|
||||
Object.assign(n, patch)
|
||||
router.push({ name: 'gallery', query: filterToQuery(n) })
|
||||
}
|
||||
|
||||
function selectPlatform(key) {
|
||||
// Single-select, click-active-to-clear (the platform param is one value).
|
||||
pushPatch({ platform: store.filter.platform === key ? null : key })
|
||||
}
|
||||
function toggleFlag(name) {
|
||||
pushPatch({ [name]: !store.filter[name] })
|
||||
}
|
||||
function setDate(field, val) {
|
||||
pushPatch({ [field]: val || null })
|
||||
}
|
||||
|
||||
// Fetch when the panel opens; refetch (debounced) whenever the active filter
|
||||
// changes so the live counts track the current scope. Never fires on scroll —
|
||||
// the panel is the only caller of loadFacets.
|
||||
onMounted(() => store.loadFacets())
|
||||
|
||||
let debounce = null
|
||||
watch(() => store.filter, () => {
|
||||
if (debounce) clearTimeout(debounce)
|
||||
debounce = setTimeout(() => store.loadFacets(), 250)
|
||||
})
|
||||
onBeforeUnmount(() => { if (debounce) clearTimeout(debounce) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-facets {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 4px 12px 12px;
|
||||
}
|
||||
.fc-facets__bar { position: absolute; top: 0; left: 0; right: 0; }
|
||||
.fc-facets__group { display: flex; align-items: center; gap: 8px; }
|
||||
.fc-facets__label {
|
||||
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-facets__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
/* The count rides inside the chip as a dimmer trailing number. */
|
||||
.fc-facets__count {
|
||||
margin-left: 6px;
|
||||
opacity: 0.7;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.78em;
|
||||
}
|
||||
.fc-facets__empty { color: rgb(var(--v-theme-on-surface-variant)); font-size: 0.8rem; }
|
||||
.fc-facets__date { max-width: 160px; }
|
||||
.fc-facets__dash { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="fc-filterbar">
|
||||
<div class="fc-filterbar-wrap">
|
||||
<div class="fc-filterbar">
|
||||
<v-autocomplete
|
||||
v-model="selected"
|
||||
:items="searchItems"
|
||||
@@ -59,10 +60,21 @@
|
||||
@update:model-value="setSort"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
:color="refineOpen ? 'accent' : undefined"
|
||||
:variant="refineOpen || hasRefineFilters ? 'tonal' : 'text'"
|
||||
size="small"
|
||||
:append-icon="refineOpen ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||
@click="toggleRefine"
|
||||
>Refine{{ refineCount ? ` (${refineCount})` : '' }}</v-btn>
|
||||
|
||||
<v-btn
|
||||
v-if="hasActiveFilters" variant="text" size="small"
|
||||
prepend-icon="mdi-close" @click="clearAll"
|
||||
>Clear</v-btn>
|
||||
</div>
|
||||
|
||||
<GalleryFacetPanel v-if="refineOpen" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -70,8 +82,9 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useApi } from '../../composables/useApi.js'
|
||||
import { useGalleryStore } from '../../stores/gallery.js'
|
||||
import { cloneFilter, filterToQuery, useGalleryStore } from '../../stores/gallery.js'
|
||||
import { useTagStore } from '../../stores/tags.js'
|
||||
import GalleryFacetPanel from './GalleryFacetPanel.vue'
|
||||
|
||||
const store = useGalleryStore()
|
||||
const tagStore = useTagStore()
|
||||
@@ -88,13 +101,31 @@ const searchItems = ref([])
|
||||
const searchLoading = ref(false)
|
||||
let debounce = null
|
||||
|
||||
// The faceted-refine sub-filters (platform / curation flags / date range).
|
||||
const refineCount = computed(() => {
|
||||
const f = store.filter
|
||||
return (f.platform ? 1 : 0) + (f.untagged ? 1 : 0) + (f.no_artist ? 1 : 0)
|
||||
+ (f.date_from ? 1 : 0) + (f.date_to ? 1 : 0)
|
||||
})
|
||||
const hasRefineFilters = computed(() => refineCount.value > 0)
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
store.filter.tag_ids.length > 0 ||
|
||||
store.filter.artist_id != null ||
|
||||
store.filter.media_type != null ||
|
||||
store.filter.sort !== 'newest'
|
||||
store.filter.sort !== 'newest' ||
|
||||
hasRefineFilters.value
|
||||
)
|
||||
|
||||
// Auto-open the panel when arriving with refine filters already in the URL
|
||||
// (deep-link / back button) so the active facets are visible, not hidden.
|
||||
const refineOpen = ref(hasRefineFilters.value)
|
||||
function toggleRefine() {
|
||||
// The panel fetches facets itself on mount (and refetches on filter change);
|
||||
// opening it is enough.
|
||||
refineOpen.value = !refineOpen.value
|
||||
}
|
||||
|
||||
function iconFor(raw) {
|
||||
if (raw.kind === 'artist') return 'mdi-account'
|
||||
return { character: 'mdi-account-circle', fandom: 'mdi-book-open-page-variant',
|
||||
@@ -162,27 +193,19 @@ 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.
|
||||
// cloneFilter/filterToQuery are shared with GalleryFacetPanel so the refine
|
||||
// sub-filters survive a bar push and vice versa.
|
||||
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,
|
||||
}
|
||||
const n = cloneFilter(store.filter)
|
||||
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 })
|
||||
router.push({ name: 'gallery', query: filterToQuery(n) })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Pinned directly under the 64px TopNav and visually continuous with it. */
|
||||
.fc-filterbar {
|
||||
/* The whole chrome (bar row + expandable refine panel) is one sticky,
|
||||
hazey block pinned directly under the 64px TopNav and continuous with it. */
|
||||
.fc-filterbar-wrap {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
z-index: 5;
|
||||
@@ -190,11 +213,6 @@ function pushFilter(mutate) {
|
||||
so the bar sits flush at 64px even at scroll 0 — without this it detaches
|
||||
and a gap shows through when scrolled to the top. */
|
||||
margin-top: -8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
/* Same hazey obsidian (#14171A = 20,23,26) + blur as the TopNav so the two
|
||||
read as one piece of chrome; content scrolls under both. */
|
||||
@@ -202,10 +220,17 @@ function pushFilter(mutate) {
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
}
|
||||
.fc-filterbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
/* The inputs/toggles float on the haze: still translucent, but a touch more
|
||||
opaque than the bar/nav so the controls stay legible. */
|
||||
.fc-filterbar :deep(.v-field),
|
||||
.fc-filterbar :deep(.v-btn-group) {
|
||||
.fc-filterbar-wrap :deep(.v-field),
|
||||
.fc-filterbar-wrap :deep(.v-btn-group) {
|
||||
background-color: rgba(20, 23, 26, 0.72);
|
||||
}
|
||||
.fc-filterbar__search { max-width: 320px; min-width: 200px; }
|
||||
|
||||
@@ -24,7 +24,16 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
const filter = ref({
|
||||
tag_ids: [], artist_id: null, media_type: null,
|
||||
sort: 'newest', post_id: null,
|
||||
// Phase-2 faceted refine params.
|
||||
platform: null, untagged: false, no_artist: false,
|
||||
date_from: null, date_to: null,
|
||||
})
|
||||
|
||||
// Live facet counts for the refine panel; fetched on-demand (panel open +
|
||||
// filter change), never on plain scroll. Null until first load.
|
||||
const facets = ref(null)
|
||||
const facetsLoading = ref(false)
|
||||
const facetsInflight = useInflightToken()
|
||||
// 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
|
||||
@@ -100,9 +109,31 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
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
|
||||
if (filter.value.platform) p.platform = filter.value.platform
|
||||
if (filter.value.untagged) p.untagged = '1'
|
||||
if (filter.value.no_artist) p.no_artist = '1'
|
||||
if (filter.value.date_from) p.date_from = filter.value.date_from
|
||||
if (filter.value.date_to) p.date_to = filter.value.date_to
|
||||
return p
|
||||
}
|
||||
|
||||
// Live facet counts, scoped to the current filter (panel-gated — callers
|
||||
// are the refine panel only). Single-flighted so rapid toggles don't let a
|
||||
// stale response clobber a newer one.
|
||||
async function loadFacets() {
|
||||
facetsLoading.value = true
|
||||
const t = facetsInflight.claim()
|
||||
try {
|
||||
const body = await api.get('/api/gallery/facets', { params: activeFilterParam() })
|
||||
if (!t.isCurrent()) return
|
||||
facets.value = body
|
||||
} catch (e) {
|
||||
if (t.isCurrent()) error.value = e.message
|
||||
} finally {
|
||||
if (t.isCurrent()) facetsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -114,12 +145,22 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
media_type: ['image', 'video'].includes(q.media) ? q.media : null,
|
||||
sort: q.sort === 'oldest' ? 'oldest' : 'newest',
|
||||
post_id: _toId(q.post_id),
|
||||
platform: q.platform || null,
|
||||
untagged: _truthy(q.untagged),
|
||||
no_artist: _truthy(q.no_artist),
|
||||
date_from: _parseDate(q.date_from),
|
||||
date_to: _parseDate(q.date_to),
|
||||
}
|
||||
await loadInitial()
|
||||
await loadTimeline()
|
||||
_resolveLabels()
|
||||
}
|
||||
|
||||
function _truthy(v) { return v === '1' || v === 'true' || v === true }
|
||||
function _parseDate(v) {
|
||||
return typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : null
|
||||
}
|
||||
|
||||
function _toId(v) {
|
||||
const n = Number(v)
|
||||
return Number.isInteger(n) && n > 0 ? n : null
|
||||
@@ -158,11 +199,37 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
return {
|
||||
images, dateGroups, hasMore, isEmpty, loading, error,
|
||||
filter, tagLabels, artistLabel, timelineBuckets, timelineLoading,
|
||||
loadInitial, loadMore, loadTimeline, jumpTo,
|
||||
facets, facetsLoading,
|
||||
loadInitial, loadMore, loadTimeline, jumpTo, loadFacets,
|
||||
applyFilterFromQuery, noteTagLabel, noteArtistLabel,
|
||||
}
|
||||
})
|
||||
|
||||
// Shared by GalleryFilterBar and GalleryFacetPanel so both write the URL in
|
||||
// one format. post_id is intentionally absent — the bar/panel are hidden in
|
||||
// the exclusive post-detail view.
|
||||
export function cloneFilter(f) {
|
||||
return {
|
||||
tag_ids: [...f.tag_ids], artist_id: f.artist_id, media_type: f.media_type,
|
||||
sort: f.sort, platform: f.platform, untagged: f.untagged,
|
||||
no_artist: f.no_artist, date_from: f.date_from, date_to: f.date_to,
|
||||
}
|
||||
}
|
||||
|
||||
export function filterToQuery(f) {
|
||||
const q = {}
|
||||
if (f.tag_ids?.length) q.tag_id = f.tag_ids.join(',')
|
||||
if (f.artist_id) q.artist_id = String(f.artist_id)
|
||||
if (f.media_type) q.media = f.media_type
|
||||
if (f.sort && f.sort !== 'newest') q.sort = f.sort
|
||||
if (f.platform) q.platform = f.platform
|
||||
if (f.untagged) q.untagged = '1'
|
||||
if (f.no_artist) q.no_artist = '1'
|
||||
if (f.date_from) q.date_from = f.date_from
|
||||
if (f.date_to) q.date_to = f.date_to
|
||||
return q
|
||||
}
|
||||
|
||||
function mergeGroups(existing, incoming) {
|
||||
// Merge sequential groups with the same (year, month) instead of duplicating.
|
||||
const merged = [...existing]
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
|
||||
import GalleryFacetPanel from '../../src/components/gallery/GalleryFacetPanel.vue'
|
||||
import { useGalleryStore } from '../../src/stores/gallery.js'
|
||||
import { freshPinia, mountComponent } from '../support/mountComponent.js'
|
||||
|
||||
const FACETS = {
|
||||
total: 5,
|
||||
platforms: [
|
||||
{ value: 'patreon', count: 3 },
|
||||
{ value: null, count: 2 }, // unsourced → "No platform" bucket
|
||||
],
|
||||
untagged: 4,
|
||||
no_artist: 1,
|
||||
date_min: '2020-01-01T00:00:00+00:00',
|
||||
date_max: '2026-06-01T00:00:00+00:00',
|
||||
}
|
||||
|
||||
function stubFetchOk(body) {
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
ok: true, status: 200, statusText: 'OK',
|
||||
text: async () => JSON.stringify(body),
|
||||
}))
|
||||
}
|
||||
|
||||
describe('GalleryFacetPanel', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('renders platform options with counts and the curation flags', () => {
|
||||
const pinia = freshPinia()
|
||||
stubFetchOk(FACETS) // covers the onMounted loadFacets() call
|
||||
const s = useGalleryStore()
|
||||
s.facets = FACETS // seed so counts render synchronously
|
||||
const w = mountComponent(GalleryFacetPanel, { pinia })
|
||||
const t = w.text()
|
||||
expect(t).toContain('patreon')
|
||||
expect(t).toContain('3')
|
||||
expect(t).toContain('No platform')
|
||||
expect(t).toContain('Untagged')
|
||||
expect(t).toContain('4')
|
||||
expect(t).toContain('No artist')
|
||||
})
|
||||
|
||||
it('shows an en-dash placeholder for a flag count before facets load', () => {
|
||||
const pinia = freshPinia()
|
||||
stubFetchOk(FACETS)
|
||||
useGalleryStore().facets = null // not loaded yet
|
||||
const w = mountComponent(GalleryFacetPanel, { pinia })
|
||||
expect(w.text()).toContain('Untagged')
|
||||
expect(w.text()).toContain('–')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useGalleryStore } from '../src/stores/gallery.js'
|
||||
import { cloneFilter, filterToQuery, useGalleryStore } from '../src/stores/gallery.js'
|
||||
|
||||
function stubFetch(handler) {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
@@ -73,6 +73,55 @@ describe('gallery store: composable filter', () => {
|
||||
expect(s.artistLabel).toBe('Kubo')
|
||||
})
|
||||
|
||||
it('parses faceted refine params and loadMore forwards them', async () => {
|
||||
const s = useGalleryStore()
|
||||
const urls = []
|
||||
stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
|
||||
await s.applyFilterFromQuery({
|
||||
platform: 'pixiv', untagged: '1', no_artist: '1',
|
||||
date_from: '2024-01-01', date_to: '2024-12-31',
|
||||
})
|
||||
expect(s.filter.platform).toBe('pixiv')
|
||||
expect(s.filter.untagged).toBe(true)
|
||||
expect(s.filter.no_artist).toBe(true)
|
||||
expect(s.filter.date_from).toBe('2024-01-01')
|
||||
expect(s.filter.date_to).toBe('2024-12-31')
|
||||
const scroll = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/scroll')))
|
||||
expect(scroll).toContain('platform=pixiv')
|
||||
expect(scroll).toContain('untagged=1')
|
||||
expect(scroll).toContain('no_artist=1')
|
||||
expect(scroll).toContain('date_from=2024-01-01')
|
||||
expect(scroll).toContain('date_to=2024-12-31')
|
||||
})
|
||||
|
||||
it('drops a malformed date in the query', async () => {
|
||||
const s = useGalleryStore()
|
||||
stubFetch(() => ({ status: 200, body: EMPTY }))
|
||||
await s.applyFilterFromQuery({ date_from: 'garbage' })
|
||||
expect(s.filter.date_from).toBe(null)
|
||||
})
|
||||
|
||||
it('loadFacets fetches counts scoped to the active filter', async () => {
|
||||
const s = useGalleryStore()
|
||||
const urls = []
|
||||
const FACETS = {
|
||||
total: 4, platforms: [{ value: 'patreon', count: 2 }],
|
||||
untagged: 1, no_artist: 0,
|
||||
date_min: '2020-01-01T00:00:00+00:00', date_max: '2026-06-01T00:00:00+00:00',
|
||||
}
|
||||
stubFetch((url) => {
|
||||
urls.push(url)
|
||||
if (url.includes('/api/gallery/facets')) return { status: 200, body: FACETS }
|
||||
return { status: 200, body: EMPTY }
|
||||
})
|
||||
await s.applyFilterFromQuery({ platform: 'patreon', untagged: '1' })
|
||||
await s.loadFacets()
|
||||
const facetsUrl = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/facets')))
|
||||
expect(facetsUrl).toContain('platform=patreon')
|
||||
expect(facetsUrl).toContain('untagged=1')
|
||||
expect(s.facets.total).toBe(4)
|
||||
})
|
||||
|
||||
it('loadInitial issues exactly one scroll request at the initial limit', async () => {
|
||||
const s = useGalleryStore()
|
||||
const urls = []
|
||||
@@ -88,3 +137,34 @@ describe('gallery store: composable filter', () => {
|
||||
expect(scrollCalls[0]).toContain('limit=50')
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterToQuery / cloneFilter', () => {
|
||||
it('serializes faceted params incl. platform sentinel and flags', () => {
|
||||
const q = filterToQuery({
|
||||
tag_ids: [3], artist_id: null, media_type: null, sort: 'newest',
|
||||
platform: '__unsourced__', untagged: true, no_artist: false,
|
||||
date_from: '2024-01-01', date_to: null,
|
||||
})
|
||||
expect(q.tag_id).toBe('3')
|
||||
expect(q.platform).toBe('__unsourced__')
|
||||
expect(q.untagged).toBe('1')
|
||||
expect(q.no_artist).toBeUndefined()
|
||||
expect(q.date_from).toBe('2024-01-01')
|
||||
expect(q.date_to).toBeUndefined()
|
||||
expect(q.sort).toBeUndefined() // newest is the default → omitted
|
||||
})
|
||||
|
||||
it('cloneFilter copies refine fields and detaches tag_ids', () => {
|
||||
const orig = {
|
||||
tag_ids: [1], artist_id: 2, media_type: 'image', sort: 'oldest',
|
||||
platform: 'patreon', untagged: true, no_artist: true,
|
||||
date_from: '2024-01-01', date_to: '2024-02-01',
|
||||
}
|
||||
const c = cloneFilter(orig)
|
||||
c.tag_ids.push(9)
|
||||
expect(orig.tag_ids).toEqual([1]) // detached
|
||||
expect(c.platform).toBe('patreon')
|
||||
expect(c.untagged).toBe(true)
|
||||
expect(c.date_to).toBe('2024-02-01')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user