90686e6deb
AppSnackbar mounts once at the app root and exposes a window.__fcToast function the stores call from error paths. This avoids prop-drilling a toast emitter through every component while keeping the snackbar component itself testable in isolation. GalleryGrid renders shimmer-skeleton placeholders on initial load so the first paint isn't empty. Skeleton uses the same auto-fill grid columns as the real items so layout doesn't shift when content arrives. Modal arrow nav now ignores text-entry elements so typing in the tag autocomplete doesn't navigate prev/next. Small-screen breakpoint (<600px) tightens gallery thumbnails to 120px min. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
132 lines
3.7 KiB
Vue
132 lines
3.7 KiB
Vue
<template>
|
|
<div class="fc-gallery-grid">
|
|
<div v-if="store.loading && store.images.length === 0" class="fc-gallery-grid__skeleton">
|
|
<div v-for="i in 12" :key="i" class="fc-gallery-grid__skeleton-item" />
|
|
</div>
|
|
<template v-for="group in store.dateGroups" :key="`${group.year}-${group.month}`">
|
|
<h2 class="fc-gallery-grid__date-header" :id="dateHeaderId(group)">
|
|
{{ monthLabel(group.year, group.month) }}
|
|
</h2>
|
|
<div class="fc-gallery-grid__items">
|
|
<GalleryItem
|
|
v-for="img in imagesForGroup(group)"
|
|
:key="img.id" :image="img" @open="$emit('open', img.id)"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-if="store.loading" class="fc-gallery-grid__sentinel">
|
|
<v-progress-circular indeterminate color="accent" size="28" />
|
|
</div>
|
|
<div v-else-if="store.hasMore" ref="sentinelEl" class="fc-gallery-grid__sentinel" />
|
|
<div v-else-if="store.images.length" class="fc-gallery-grid__end text-caption">
|
|
End of gallery.
|
|
</div>
|
|
|
|
<v-alert
|
|
v-if="store.error" type="error" variant="tonal" closable class="my-4"
|
|
>
|
|
Failed to load: {{ store.error }}
|
|
</v-alert>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
|
import { useGalleryStore } from '../../stores/gallery.js'
|
|
import GalleryItem from './GalleryItem.vue'
|
|
|
|
defineEmits(['open'])
|
|
|
|
const store = useGalleryStore()
|
|
const sentinelEl = ref(null)
|
|
|
|
let observer = null
|
|
|
|
function attachObserver() {
|
|
if (observer) observer.disconnect()
|
|
if (!sentinelEl.value) return
|
|
observer = new IntersectionObserver(([entry]) => {
|
|
if (entry.isIntersecting && store.hasMore && !store.loading) {
|
|
store.loadMore()
|
|
}
|
|
}, { rootMargin: '600px' })
|
|
observer.observe(sentinelEl.value)
|
|
}
|
|
|
|
watch(sentinelEl, attachObserver)
|
|
onMounted(() => attachObserver())
|
|
onUnmounted(() => observer && observer.disconnect())
|
|
|
|
function dateHeaderId(group) { return `fc-month-${group.year}-${group.month}` }
|
|
|
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
function monthLabel(y, m) { return `${MONTHS[m - 1]} ${y}` }
|
|
|
|
function imagesForGroup(group) {
|
|
const ids = new Set(group.image_ids)
|
|
return store.images.filter(i => ids.has(i.id))
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-gallery-grid {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.fc-gallery-grid__date-header {
|
|
font-family: 'Fraunces', Georgia, serif;
|
|
font-size: 20px;
|
|
font-weight: 500;
|
|
margin: 24px 0 12px;
|
|
padding-bottom: 6px;
|
|
border-bottom: 1px solid rgb(var(--v-theme-surface-light));
|
|
color: rgb(var(--v-theme-on-surface));
|
|
}
|
|
.fc-gallery-grid__items {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
gap: 8px;
|
|
}
|
|
.fc-gallery-grid__sentinel {
|
|
display: flex; justify-content: center; align-items: center;
|
|
padding: 32px 0;
|
|
min-height: 60px;
|
|
}
|
|
.fc-gallery-grid__end {
|
|
text-align: center;
|
|
padding: 32px 0;
|
|
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
|
|
}
|
|
.fc-gallery-grid__skeleton {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
gap: 8px;
|
|
}
|
|
.fc-gallery-grid__skeleton-item {
|
|
aspect-ratio: 1;
|
|
background: linear-gradient(
|
|
90deg,
|
|
rgb(var(--v-theme-surface)) 0%,
|
|
rgb(var(--v-theme-surface-light)) 50%,
|
|
rgb(var(--v-theme-surface)) 100%
|
|
);
|
|
background-size: 200% 100%;
|
|
animation: fc-shimmer 1.4s infinite;
|
|
border-radius: 4px;
|
|
}
|
|
@keyframes fc-shimmer {
|
|
0% { background-position: 200% 0; }
|
|
100% { background-position: -200% 0; }
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.fc-gallery-grid__items,
|
|
.fc-gallery-grid__skeleton {
|
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
|
gap: 4px;
|
|
}
|
|
}
|
|
</style>
|