import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useApi } from '../composables/useApi.js' // Operator-flagged 2026-05-30 (round 3): the all-parallel fetch was fast // but risked later chunks arriving first — undesirable even when each // chunk is a random sample. Switched to a PIPELINE: only one fetch in // flight at any moment, but the next fetch kicks off as soon as the // previous one resolves (NOT after its trickle finishes). The next RTT // overlaps with the current batch's trickle, hiding the per-batch // round-trip behind the visible animation cadence. Responses arrive in // fire-order, so no out-of-order rendering surprises. // // Smaller PAGE (3 vs 5) → first chunk's items appear sooner: a chunk of // 3 trickles in 240 ms, well within one RTT, so by the time chunk 2 is // in-hand the trickle is just finishing. Total wall-clock is roughly // RTT + N × max(trickle_time, RTT); APPEND_DELAY_MS keeps the visible // cadence smooth throughout. const PAGE = 3 const INITIAL_BATCHES = 20 const APPEND_DELAY_MS = 80 // ≈ the MasonryGrid stagger animation (70 ms) // Operator-flagged 2026-06-01: scrolling the showcase eventually hit a // premature "End." because /api/showcase returns a *random sample* and // after enough scrolling the `seen` Set accumulated enough to fully // collide with a 3-item batch. The showcase is supposed to be endless; // only a genuinely empty API response (library has zero images) should // mark it exhausted. Retry up to FETCH_RETRY_CAP times on all-dupe // batches; only flip `exhausted` when the API returns 0 items OR every // retry came back dupe-only (graceful fallback for tiny libraries // where retries will keep returning the same handful of items). const FETCH_RETRY_CAP = 8 function _sleep(ms) { return new Promise(r => setTimeout(r, ms)) } export const useShowcaseStore = defineStore('showcase', () => { const api = useApi() const images = ref([]) const loading = ref(false) const error = ref(null) const exhausted = ref(false) const seen = new Set() // Sequence token: every call to loadInitial bumps this. _trickleAppend // bails between items if its captured seq is no longer current — guards // against a fast shuffle / mount-then-shuffle from interleaving two // trickles into the same images.value. let _seq = 0 async function _trickleAppend(items, mySeq) { for (const item of items) { if (mySeq !== _seq) return if (seen.has(item.id)) continue seen.add(item.id) images.value.push(item) await _sleep(APPEND_DELAY_MS) } } // Single batch — used by infinite-scroll appends. Trickles its items // in for the same one-at-a-time cadence as the initial load. Retries // up to FETCH_RETRY_CAP times when the API's random sample comes back // all-duplicates (the showcase is endless by design; only a genuinely // empty API response should mark it exhausted, not an unlucky sample). async function fetchPage() { if (loading.value) return loading.value = true error.value = null try { for (let attempt = 0; attempt < FETCH_RETRY_CAP; attempt++) { const body = await api.get('/api/showcase', { params: { limit: PAGE } }) const items = body.images || [] // API genuinely empty → library is empty / endpoint exhausted. if (items.length === 0) { exhausted.value = true; return } const fresh = items.filter(i => !seen.has(i.id)) if (fresh.length > 0) { await _trickleAppend(fresh, _seq) return } // All-dupes batch — keep trying. Showcase is endless by intent. } // Retry cap hit with zero fresh items: library is probably much // smaller than the running `seen` set, fall back to exhausted so // the UI stops trying. Operator can shuffle to reset `seen`. exhausted.value = true } catch (e) { error.value = e.message || String(e) } finally { loading.value = false } } function _fetchOne() { return api.get('/api/showcase', { params: { limit: PAGE } }).catch(e => { error.value = error.value || (e.message || String(e)) return null }) } // Reset state and pipeline INITIAL_BATCHES fetches: only one in flight // at a time, but kick off the next one as soon as the previous resolves // (NOT after its trickle finishes), so the next RTT runs concurrently // with the current batch's trickle. Responses arrive in fire-order, so // items always render in the order they were fetched — no out-of-order // surprises from parallel races. async function loadInitial() { _seq += 1 const mySeq = _seq images.value = [] seen.clear() exhausted.value = false error.value = null loading.value = true try { let nextFetch = _fetchOne() for (let i = 0; i < INITIAL_BATCHES; i++) { if (mySeq !== _seq) return const body = await nextFetch // Fire the NEXT fetch immediately so its RTT overlaps the trickle. if (i + 1 < INITIAL_BATCHES) nextFetch = _fetchOne() if (!body || !body.images || body.images.length === 0) { exhausted.value = true break } await _trickleAppend(body.images, mySeq) } if (mySeq === _seq && images.value.length === 0) exhausted.value = true } finally { if (mySeq === _seq) loading.value = false } } async function shuffle() { await loadInitial() } const hasMore = computed(() => !exhausted.value) const isEmpty = computed( () => !loading.value && images.value.length === 0 && error.value === null ) return { images, loading, error, hasMore, isEmpty, fetchPage, shuffle, loadInitial } })