56cc253009
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>
176 lines
6.0 KiB
Vue
176 lines
6.0 KiB
Vue
<template>
|
|
<v-card
|
|
class="fc-gallery-item" elevation="0"
|
|
:class="{ 'fc-gallery-item--selected': selected }"
|
|
@click="onClick"
|
|
:aria-label="`Image ${image.id}`" tabindex="0"
|
|
@keydown.enter="onClick"
|
|
@keydown.space.prevent="onClick"
|
|
>
|
|
<div class="fc-gallery-item__media">
|
|
<img
|
|
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
|
|
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">
|
|
<v-icon size="32" icon="mdi-image-broken-variant" />
|
|
</div>
|
|
<template v-if="sel.isSelectMode">
|
|
<div class="fc-gallery-item__checkbox" :class="{ on: selected }">
|
|
<v-icon v-if="selected" size="16" icon="mdi-check" />
|
|
</div>
|
|
<div v-if="selected" class="fc-gallery-item__order">{{ orderNum }}</div>
|
|
</template>
|
|
<div v-if="image.artist" class="fc-gallery-item__artist">
|
|
<RouterLink
|
|
v-if="!sel.isSelectMode"
|
|
:to="`/artist/${image.artist.slug}`"
|
|
@click.stop
|
|
>{{ image.artist.name }}</RouterLink>
|
|
<span v-else>{{ image.artist.name }}</span>
|
|
</div>
|
|
</div>
|
|
</v-card>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import { useGallerySelectionStore } from '../../stores/gallerySelection.js'
|
|
|
|
const props = defineProps({ image: { type: Object, required: true } })
|
|
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/')
|
|
)
|
|
const selected = computed(() => sel.isSelected(props.image.id))
|
|
const orderNum = computed(() => sel.order.indexOf(props.image.id) + 1)
|
|
|
|
function onClick() {
|
|
if (sel.isSelectMode) sel.toggle(props.image.id)
|
|
else emit('open', props.image.id)
|
|
}
|
|
function onThumbError() { thumbError.value = true }
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-gallery-item {
|
|
cursor: pointer;
|
|
overflow: hidden;
|
|
background: rgb(var(--v-theme-surface));
|
|
transition: transform 120ms ease, box-shadow 120ms ease;
|
|
}
|
|
.fc-gallery-item:hover, .fc-gallery-item:focus-visible {
|
|
transform: translateY(-1px);
|
|
outline: 2px solid rgb(var(--v-theme-accent));
|
|
}
|
|
.fc-gallery-item--selected {
|
|
outline: 3px solid rgb(var(--v-theme-accent));
|
|
outline-offset: -3px;
|
|
}
|
|
.fc-gallery-item__media {
|
|
position: relative;
|
|
aspect-ratio: 1;
|
|
overflow: hidden;
|
|
background: rgb(var(--v-theme-surface-light));
|
|
}
|
|
.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 {
|
|
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
|
color: rgba(232, 228, 216, 0.85);
|
|
font-size: 36px;
|
|
pointer-events: none;
|
|
}
|
|
.fc-gallery-item__placeholder {
|
|
position: absolute; inset: 0; display: grid; place-items: center;
|
|
background: rgb(var(--v-theme-surface-light));
|
|
}
|
|
.fc-gallery-item__checkbox {
|
|
position: absolute; top: 8px; left: 8px;
|
|
width: 22px; height: 22px; border-radius: 4px;
|
|
border: 2px solid rgba(232, 228, 216, 0.8);
|
|
background: rgba(20, 23, 26, 0.45);
|
|
display: grid; place-items: center;
|
|
color: #14171A; z-index: 11;
|
|
}
|
|
.fc-gallery-item__checkbox.on {
|
|
background: rgb(var(--v-theme-accent));
|
|
border-color: rgb(var(--v-theme-accent));
|
|
}
|
|
.fc-gallery-item__order {
|
|
position: absolute; top: 6px; right: 6px;
|
|
min-width: 22px; height: 22px; padding: 0 5px;
|
|
border-radius: 11px;
|
|
background: rgb(var(--v-theme-accent));
|
|
color: #14171A; font-size: 12px; font-weight: 700;
|
|
display: grid; place-items: center; z-index: 11;
|
|
pointer-events: none;
|
|
}
|
|
.fc-gallery-item__artist {
|
|
position: absolute; left: 0; right: 0; bottom: 0;
|
|
padding: 14px 8px 6px;
|
|
background: linear-gradient(
|
|
to top, rgba(20, 23, 26, 0.78), rgba(20, 23, 26, 0)
|
|
);
|
|
font-size: 12px; line-height: 1.2;
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
pointer-events: none;
|
|
}
|
|
.fc-gallery-item__artist a {
|
|
color: rgb(var(--v-theme-parchment, 232 228 216));
|
|
text-decoration: none;
|
|
pointer-events: auto;
|
|
}
|
|
.fc-gallery-item__artist span { color: rgba(232, 228, 216, 0.85); }
|
|
</style>
|