304e8aa878
The top nav packed brand + health + pipeline chip + ~7 inline links + an action slot into one flex row, colliding/overflowing on phones (operator: 'almost unusable'). Below 768px the links now fold into a hamburger v-menu; below 480px the brand text hides (glyph still brands). Plus the primary browsing path: - BulkEditorPanel: fixed 320px -> min(320px, 90vw) so it can't swallow the screen. - GalleryFilterBar: <600px gives search its own full-width row (its 200px min-width was jamming the wrapping bar); sort grows. - GalleryFacetPanel: <480px wraps groups + lets the side-by-side date inputs grow full-width. - ArtistsView grid: minmax(min(440px,100%),1fr) so a card never overflows (single column on phones). - GalleryView: hide the year/month timeline strip <600px. ImageViewer already stacks its side panel below the image <900px (left as-is). Secondary surfaces (Posts/Subscriptions filter bars, SubscriptionsTab table, SeriesReader, PostCard) still need a mobile pass — follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
5.4 KiB
Vue
154 lines
5.4 KiB
Vue
<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)); }
|
||
|
||
/* Phones: let each facet group wrap and the side-by-side date inputs grow to
|
||
full width so they don't overflow a ~360px viewport. */
|
||
@media (max-width: 480px) {
|
||
.fc-facets__group { flex-wrap: wrap; }
|
||
.fc-facets__date { max-width: none; flex: 1 1 9rem; }
|
||
}
|
||
</style>
|