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; }
|
||||
|
||||
Reference in New Issue
Block a user