Files
FabledCurator/frontend/src/stores/showcase.js
T
bvandeusen 0497394710
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 17s
CI / integration (push) Successful in 2m56s
feat(ui): double showcase cadence + filter bar matches TopNav frost
- Showcase reveal cadence 80ms -> 160ms (slower, more deliberate one-at-a-time
  cascade per operator). Bump showcase.spec timer advances to cover 60x160ms.
- Gallery filter bar now uses the EXACT gradiated-obsidian frost + blur as
  TopNav (was a flat rgba(...,0.55) block), so the two read as one continuous
  piece of chrome with images visibly scrolling under both; the nav's
  transparent bottom edge against the bar's opaque top leaves a faint seam that
  separates them at the very top of scroll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:02:21 -04:00

180 lines
6.8 KiB
JavaScript

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
import { preloadImage } from '../utils/preloadImage.js'
// Buffered producer/consumer so the cascade cadence is decoupled from fetch
// latency (operator-flagged 2026-06-04). The OLD pipeline trickled each batch
// right after its fetch and bet the next round-trip would finish inside the
// ~240ms trickle window; when a fetch ran long (TABLESAMPLE hits random,
// sometimes-cold blocks; RTT jitter) the animation starved and the view
// "burped" out a clump of images. Now:
// - PRODUCER (_fill): races ahead fetching batches into `queue` up to a
// target depth, refilling whenever the queue dips below BUFFER_MIN. It
// also kicks off the image PRELOAD for each queued item so decoding
// pipelines ahead of the reveal.
// - CONSUMER (_drain): pops ONE item, WAITS for its thumbnail to be fully
// decoded, then reveals it — at most one per CADENCE_MS. Because the
// producer preloads ahead, the decode-wait is usually already satisfied,
// so the reveal stays evenly paced without idling the network.
// The decode-gate is what makes the showcase's signature cascade land each
// tile fully-loaded (the flip-in animates a real image, never a gray
// placeholder); the single-item reveal keeps it strictly one-at-a-time
// (operator-flagged 2026-06-04). The very first image still waits on the first
// fetch + decode (a cold TABLESAMPLE is a separate, query-side concern);
// everything after it is buffer-smoothed.
const PAGE = 3
const CADENCE_MS = 160 // floor between fully-loaded reveals (doubled
// 2026-06-04 — slower, more deliberate cadence)
const PRIME = 6 // items buffered before the drain starts
const BUFFER_TARGET = 30 // producer tops the queue up to this
const BUFFER_MIN = 12 // ...and refills once the queue dips below this
const INITIAL_COUNT = 60 // cascade length on load / shuffle
const SCROLL_COUNT = 15 // cascade length per infinite-scroll demand
// Showcase is endless by design (random sample); an unlucky all-duplicate
// batch must be retried — only a genuinely empty API response is exhaustion.
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()
// Internal buffer (not reactive — the consumer is what feeds the UI via
// images.value).
let queue = []
// id -> Promise that settles when the thumbnail is paint-ready. Started by
// the producer so decoding runs ahead of the reveal; awaited by the consumer
// so no tile is shown before its image is loaded.
let _preloads = new Map()
// Sequence token: shuffle / re-mount bumps it so in-flight producers and
// the drain bail instead of interleaving two runs into one images list.
let _seq = 0
let _filling = false
let _draining = false
function _preload(item) {
if (!_preloads.has(item.id)) _preloads.set(item.id, preloadImage(item.thumbnail_url))
return _preloads.get(item.id)
}
// One batch, retried while the random sample comes back all-duplicates.
// Returns the fresh items, or null when the API is genuinely empty.
async function _fetchFresh(mySeq) {
for (let attempt = 0; attempt < FETCH_RETRY_CAP; attempt++) {
if (mySeq !== _seq) return []
const body = await api
.get('/api/showcase', { params: { limit: PAGE } })
.catch((e) => {
error.value = error.value || (e.message || String(e))
return null
})
if (mySeq !== _seq) return []
const items = (body && body.images) || []
if (items.length === 0) return null
const fresh = items.filter((i) => !seen.has(i.id))
fresh.forEach((f) => seen.add(f.id))
if (fresh.length) return fresh
}
return null // retry cap hit → tiny library, treat as exhausted
}
// Producer: top the buffer up to `target`. Single-flight.
async function _fill(mySeq, target) {
if (_filling) return
_filling = true
try {
while (mySeq === _seq && !exhausted.value && queue.length < target) {
const batch = await _fetchFresh(mySeq)
if (mySeq !== _seq) return
if (batch === null) { exhausted.value = true; return }
queue.push(...batch)
batch.forEach(_preload) // pipeline decoding ahead of the reveal
}
} finally {
_filling = false
}
}
// Consumer: show `count` items at a fixed cadence, topping up the buffer as
// it drains. Single-flight so the initial cascade and scroll appends can't
// interleave.
async function _drain(mySeq, count) {
if (_draining) return
_draining = true
loading.value = true
try {
for (let shown = 0; shown < count && mySeq === _seq; shown++) {
if (!exhausted.value && queue.length < BUFFER_MIN) {
_fill(mySeq, BUFFER_TARGET) // topup, no await
}
let guard = 0
while (queue.length === 0 && !exhausted.value && mySeq === _seq) {
await _sleep(20)
if (++guard > 500) break // 10s starvation safety net
}
if (mySeq !== _seq) return
if (queue.length === 0) return // exhausted + empty
const item = queue.shift()
await _preload(item) // reveal only once the thumbnail is fully decoded
if (mySeq !== _seq) return
images.value.push(item)
await _sleep(CADENCE_MS)
}
} finally {
if (mySeq === _seq) {
_draining = false
loading.value = false
}
}
}
async function loadInitial() {
_seq += 1
const mySeq = _seq
images.value = []
queue = []
_preloads = new Map()
seen.clear()
exhausted.value = false
error.value = null
_filling = false
_draining = false
loading.value = true
try {
// Prime a small buffer before the cascade starts so it doesn't starve
// at the front; the drain's own topup grows it to BUFFER_TARGET.
await _fill(mySeq, PRIME)
if (mySeq !== _seq) return
if (queue.length === 0 && exhausted.value) return // empty library
await _drain(mySeq, INITIAL_COUNT)
} finally {
if (mySeq === _seq) loading.value = false
}
}
async function fetchPage() {
// Infinite-scroll demand — append another cascade of SCROLL_COUNT.
if (_draining || exhausted.value) return
await _drain(_seq, SCROLL_COUNT)
}
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 }
})