feat(gallery): faceted refine panel UI (Phase 2 frontend)
CI / lint (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 22s
CI / intimp (push) Successful in 3m34s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m30s

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:
2026-06-04 07:06:51 -04:00
parent 9fe534139a
commit 1adc47f59c
5 changed files with 398 additions and 27 deletions
@@ -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; }
+68 -1
View File
@@ -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('')
})
})
+81 -1
View File
@@ -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')
})
})