feat(gallery): reveal tiles on image load + single initial fetch
The 5×10 metadata batching only staggered the cheap layer (JSON); thumbnails load as independent <img> requests and clustered, so tiles "popped in together" after a wait. Two changes: - GalleryItem reveals each tile when ITS OWN thumbnail fires @load (with an onMounted complete-check for cached thumbs), playing a showcase-style flip-up entrance. Tiles now cascade in natural load order instead of all at once. Honors prefers-reduced-motion. - gallery store does ONE initial fetch (limit=50) instead of 10 serial /scroll round-trips. Fewer RTTs, faster first paint; the reveal-on-load is what makes appearance progressive now. Infinite scroll pulls 25/trigger. Tests: GalleryItem gains is-loaded only after @load; loadInitial issues exactly one scroll request at the initial limit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,11 +9,16 @@
|
||||
>
|
||||
<div class="fc-gallery-item__media">
|
||||
<img
|
||||
v-if="!isVideo" :src="image.thumbnail_url" :alt="`Image ${image.id}`"
|
||||
loading="lazy" @error="onThumbError"
|
||||
v-if="!isVideo" ref="imgEl" :src="image.thumbnail_url"
|
||||
:alt="`Image ${image.id}`" loading="lazy"
|
||||
:class="{ 'is-loaded': loaded }"
|
||||
@load="loaded = true" @error="onThumbError"
|
||||
>
|
||||
<div v-else class="fc-gallery-item__video-thumb">
|
||||
<img :src="image.thumbnail_url" :alt="`Video ${image.id}`" loading="lazy">
|
||||
<img
|
||||
ref="imgEl" :src="image.thumbnail_url" :alt="`Video ${image.id}`"
|
||||
loading="lazy" :class="{ 'is-loaded': loaded }" @load="loaded = true"
|
||||
>
|
||||
<v-icon class="fc-gallery-item__video-badge" icon="mdi-play-circle" />
|
||||
</div>
|
||||
<div v-if="thumbError" class="fc-gallery-item__placeholder">
|
||||
@@ -38,7 +43,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useGallerySelectionStore } from '../../stores/gallerySelection.js'
|
||||
|
||||
const props = defineProps({ image: { type: Object, required: true } })
|
||||
@@ -46,6 +51,18 @@ const emit = defineEmits(['open'])
|
||||
|
||||
const sel = useGallerySelectionStore()
|
||||
const thumbError = ref(false)
|
||||
|
||||
// Reveal each tile when ITS OWN thumbnail finishes loading (`@load`), not
|
||||
// when its metadata batch lands — so tiles fade/flip in individually in
|
||||
// load order instead of popping in together (operator-flagged 2026-06-04).
|
||||
const loaded = ref(false)
|
||||
const imgEl = ref(null)
|
||||
onMounted(() => {
|
||||
// A cached thumbnail can already be complete before @load binds; reflect
|
||||
// that so the tile reveals instead of sitting invisible at opacity 0.
|
||||
const el = imgEl.value
|
||||
if (el && el.complete && el.naturalWidth > 0) loaded.value = true
|
||||
})
|
||||
const isVideo = computed(
|
||||
() => props.image.mime && props.image.mime.startsWith('video/')
|
||||
)
|
||||
@@ -82,6 +99,30 @@ function onThumbError() { thumbError.value = true }
|
||||
}
|
||||
.fc-gallery-item__media img {
|
||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||
opacity: 0;
|
||||
}
|
||||
/* Entrance: each thumbnail flips up out of a slight backward tilt and
|
||||
settles into place, played WHEN ITS OWN image finishes loading (the
|
||||
`is-loaded` class) rather than on a batch/timer — so tiles cascade in
|
||||
natural load order instead of popping in together. Mirrors the showcase
|
||||
MasonryGrid entrance styling (operator-flagged 2026-06-04). */
|
||||
.fc-gallery-item__media img.is-loaded {
|
||||
animation: fc-gallery-item-in 0.5s cubic-bezier(0.34, 1.45, 0.64, 1) both;
|
||||
}
|
||||
@keyframes fc-gallery-item-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: perspective(1000px) rotateX(-22deg) translateY(20px) scale(0.96);
|
||||
}
|
||||
55% { opacity: 1; }
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: perspective(1000px) rotateX(0) translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fc-gallery-item__media img { opacity: 1; }
|
||||
.fc-gallery-item__media img.is-loaded { animation: none; }
|
||||
}
|
||||
.fc-gallery-item__video-thumb { position: relative; height: 100%; }
|
||||
.fc-gallery-item__video-badge {
|
||||
|
||||
@@ -3,12 +3,15 @@ import { ref, computed } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||
|
||||
// Operator-confirmed 2026-05-30: fetch PAGE-sized chunks instead of one
|
||||
// 50-item request so items render as each batch lands. Total initial
|
||||
// count is unchanged (PAGE * INITIAL_BATCHES = 50). Infinite-scroll also
|
||||
// pulls PAGE per trigger to keep appends progressive.
|
||||
const PAGE = 5
|
||||
const INITIAL_BATCHES = 10
|
||||
// Initial paint is a SINGLE request (INITIAL_LIMIT). The old 10×serial-
|
||||
// batch loop (2026-05-30) only staggered METADATA, which isn't the visual
|
||||
// bottleneck — thumbnails load as independent `<img>` requests, and
|
||||
// GalleryItem now reveals each tile on its own image `@load`. One fetch is
|
||||
// far fewer round-trips and faster to first paint; the reveal-on-load is
|
||||
// what makes appearance progressive. Infinite scroll pulls PAGE per trigger.
|
||||
// Reworked 2026-06-04.
|
||||
const PAGE = 25
|
||||
const INITIAL_LIMIT = 50
|
||||
|
||||
export const useGalleryStore = defineStore('gallery', () => {
|
||||
const api = useApi()
|
||||
@@ -32,22 +35,16 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
images.value = []
|
||||
dateGroups.value = []
|
||||
nextCursor.value = null
|
||||
// Sequentially fetch INITIAL_BATCHES chunks so items render as each
|
||||
// batch lands rather than blocking on one big response. Stop early
|
||||
// when the backend reports no more pages.
|
||||
for (let i = 0; i < INITIAL_BATCHES; i++) {
|
||||
if (i > 0 && nextCursor.value === null) break
|
||||
await loadMore()
|
||||
}
|
||||
await loadMore(INITIAL_LIMIT)
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
async function loadMore(limit = PAGE) {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const t = inflight.claim()
|
||||
try {
|
||||
const params = { limit: PAGE, ...activeFilterParam() }
|
||||
const params = { limit, ...activeFilterParam() }
|
||||
if (nextCursor.value) params.cursor = nextCursor.value
|
||||
const body = await api.get('/api/gallery/scroll', { params })
|
||||
if (!t.isCurrent()) return
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import GalleryItem from '../../src/components/gallery/GalleryItem.vue'
|
||||
import { freshPinia, mountComponent } from '../support/mountComponent.js'
|
||||
|
||||
describe('GalleryItem', () => {
|
||||
const image = {
|
||||
id: 7, sha256: 'a'.repeat(64), mime: 'image/jpeg',
|
||||
width: 100, height: 100,
|
||||
thumbnail_url: '/images/thumbs/aa/abc.jpg', artist: null,
|
||||
}
|
||||
|
||||
it('reveals the thumbnail only once it has loaded, so tiles fade in individually', async () => {
|
||||
const pinia = freshPinia()
|
||||
const w = mountComponent(GalleryItem, { props: { image }, pinia })
|
||||
const img = w.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
|
||||
// Pre-load: the tile must NOT be revealed, so it can fade in when its
|
||||
// own thumbnail lands rather than pop together with its API batch.
|
||||
expect(img.classes()).not.toContain('is-loaded')
|
||||
|
||||
await img.trigger('load')
|
||||
|
||||
expect(img.classes()).toContain('is-loaded')
|
||||
})
|
||||
})
|
||||
@@ -49,4 +49,19 @@ describe('gallery store: tag/post filter exclusivity', () => {
|
||||
expect(scrollCall).toContain('post_id=7')
|
||||
expect(scrollCall).not.toContain('tag_id=')
|
||||
})
|
||||
|
||||
it('loadInitial issues exactly one scroll request at the initial limit', async () => {
|
||||
const s = useGalleryStore()
|
||||
const urls = []
|
||||
// Non-null cursor: the old 10×serial-batch loop would have fired ten
|
||||
// requests here. The single-fetch path must fire exactly one.
|
||||
stubFetch((url) => {
|
||||
urls.push(url)
|
||||
return { status: 200, body: { images: [], date_groups: [], next_cursor: 'c1' } }
|
||||
})
|
||||
await s.loadInitial()
|
||||
const scrollCalls = urls.filter(u => u.includes('/api/gallery/scroll'))
|
||||
expect(scrollCalls).toHaveLength(1)
|
||||
expect(scrollCalls[0]).toContain('limit=50')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user