Files
FabledCurator/frontend/src/components/gallery/GalleryGrid.vue
T
bvandeusenandClaude Opus 4.7 8979e0e377 refactor(I4): extract useInfiniteScroll composable; migrate 6 consumers
The IntersectionObserver-on-a-sentinel infinite-scroll pattern was copy-
pasted in 7 places. New composables/useInfiniteScroll(sentinelRef, cb)
owns the observer lifecycle (attach on mount, re-attach when the ref
changes, disconnect on unmount). Migrated GalleryGrid, MasonryGrid
(gallery+showcase), ArtistPostsTab, ArtistsView, TagsView, SeriesManageView.
PostsView left manual on purpose — its anchored mode does bidirectional
scroll-position preservation that doesn't fit the simple composable.

I4(a) (timestamps) is effectively already done: the UTC displays were
converted earlier; the remaining toLocaleString uses already render local
time, so they're left as-is rather than churned for format-only consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 14:03:49 -04:00

120 lines
3.4 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 { ref } from 'vue'
import { useGalleryStore } from '../../stores/gallery.js'
import { useInfiniteScroll } from '../../composables/useInfiniteScroll.js'
import GalleryItem from './GalleryItem.vue'
defineEmits(['open'])
const store = useGalleryStore()
const sentinelEl = ref(null)
useInfiniteScroll(sentinelEl, () => {
if (store.hasMore && !store.loading) store.loadMore()
})
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(240px, 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(240px, 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(180px, 1fr));
gap: 4px;
}
}
</style>