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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user