From e36a158c69694306bbf78c392638cb1a9fbf07c9 Mon Sep 17 00:00:00 2001 From: foxtacles Date: Mon, 13 Apr 2026 19:17:24 -0700 Subject: [PATCH] Add latest memories page (#33) --- server/src/index.ts | 31 +++ src/App.svelte | 4 + src/core/navigation.js | 6 + src/core/thumbnails.js | 84 +++++-- src/lib/AccountIndicator.svelte | 14 +- src/lib/LatestMemoriesPage.svelte | 370 ++++++++++++++++++++++++++++++ src/stores.js | 6 +- worker/src/index.ts | 55 +++++ worker/wrangler.toml | 8 + 9 files changed, 551 insertions(+), 27 deletions(-) create mode 100644 src/lib/LatestMemoriesPage.svelte diff --git a/server/src/index.ts b/server/src/index.ts index 2e159a7..4ea5034 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -74,6 +74,37 @@ app.get("/api/memory/:eventId", async (c) => { }); }); +// Public endpoint: latest 10 unique memories for the global feed +app.get("/api/memories/latest", async (c) => { + const results = await c.env.DB.prepare( + `SELECT mc.anim_index, mc.event_id, mc.completed_at, mc.participants, mc.language + FROM memory_completions mc + INNER JOIN ( + SELECT event_id, MAX(completed_at) AS max_completed_at + FROM memory_completions + GROUP BY event_id + ORDER BY max_completed_at DESC + LIMIT 10 + ) latest ON mc.event_id = latest.event_id AND mc.completed_at = latest.max_completed_at + GROUP BY mc.event_id + ORDER BY mc.completed_at DESC` + ).all<{ anim_index: number; event_id: string; completed_at: number; participants: string; language: string }>(); + + const entries = results.results.map((r) => { + let participants: unknown[]; + try { participants = JSON.parse(r.participants || "[]"); } catch { participants = []; } + return { + animIndex: r.anim_index, + eventId: r.event_id, + completedAt: r.completed_at, + participants, + language: r.language, + }; + }); + + return c.json({ entries }); +}); + // Auth middleware for protected routes const authMiddleware = async (c: Context<{ Bindings: Env; Variables: Variables }>, next: Next) => { const auth = createAuth(c.env); diff --git a/src/App.svelte b/src/App.svelte index d59d2b8..b1dfa4b 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -23,6 +23,7 @@ import ConfigToast from './lib/ConfigToast.svelte'; import DebugPanel from './lib/DebugPanel.svelte'; import ScenePlayerPage from './lib/ScenePlayerPage.svelte'; + import LatestMemoriesPage from './lib/LatestMemoriesPage.svelte'; import MultiplayerOverlay from './lib/multiplayer/MultiplayerOverlay.svelte'; import WhatsNewBanner from './lib/WhatsNewBanner.svelte'; import CanvasWrapper from './lib/CanvasWrapper.svelte'; @@ -208,6 +209,9 @@
+
+ +
diff --git a/src/core/navigation.js b/src/core/navigation.js index fd01146..4cf4350 100644 --- a/src/core/navigation.js +++ b/src/core/navigation.js @@ -23,6 +23,12 @@ export function navigateToMultiplayer() { history.pushState({ page: 'multiplayer', fromApp: true }, '', '#multiplayer'); } +export function navigateToLatestMemories() { + if (get(currentPage) === 'latest-memories') return; + currentPage.set('latest-memories'); + history.pushState({ page: 'latest-memories', fromApp: true }, '', '/memories'); +} + export function navigateToMemory(eventId) { scenePlayerEventId.set(eventId); scenePlayerData.set(null); diff --git a/src/core/thumbnails.js b/src/core/thumbnails.js index 2d75b21..5470c1e 100644 --- a/src/core/thumbnails.js +++ b/src/core/thumbnails.js @@ -3,10 +3,12 @@ // building and actor thumbnails on a background thread using OffscreenCanvas. // The main thread only receives finished data URLs — zero blocking. // -// Subscribes to memoryCompletions so thumbnails are (re-)generated whenever -// the set of needed actors changes (e.g. after login, logout→login, new completions). +// Uses a work-queue pattern: any source can enqueue charIndices via +// enqueueActors(). A single worker processes the queue; when it finishes, +// any newly-queued indices trigger another run automatically. import { writable } from 'svelte/store'; import { memoryCompletions } from '../stores.js'; +import { API_URL } from './config.js'; /** Maps location label (e.g. "Pizzeria") to a data URL of the rendered building. */ export const buildingThumbnails = writable({}); @@ -14,41 +16,71 @@ export const buildingThumbnails = writable({}); /** Maps charIndex (0-65) to a data URL of the rendered actor. */ export const actorThumbnails = writable({}); -/** Collect unique charIndices from completion data. */ -function getNeededActors(completions) { - const indices = new Set(); - for (const c of completions) { - if (c.participants) { - for (const p of c.participants) { - indices.add(p.charIndex); - } - } - } - return [...indices]; -} +/** Prefetched global feed entries (null = not fetched, [] = fetched but empty). */ +export const latestMemoriesCache = writable(null); +// --- Work queue state --- let renderedActors = new Set(); let buildingsRendered = false; let activeWorker = null; +let pendingActors = new Set(); // indices waiting to be rendered + +/** + * Add charIndices to the render queue and flush if no worker is active. + * Safe to call from any source at any time — indices are deduplicated. + */ +function enqueueActors(indices) { + for (const i of indices) { + if (!renderedActors.has(i)) pendingActors.add(i); + } + flushQueue(); +} + +/** If there's pending work and no active worker, spawn one. */ +function flushQueue() { + if (activeWorker) return; + const includeBuildings = !buildingsRendered; + const batch = [...pendingActors]; + pendingActors.clear(); + if (batch.length === 0 && !includeBuildings) return; + spawnWorker(batch, includeBuildings); +} export function initThumbnails() { memoryCompletions.subscribe(completions => { if (completions === null) return; - - const needed = getNeededActors(completions); - const missing = needed.filter(i => !renderedActors.has(i)); - if (missing.length === 0 && buildingsRendered) return; - - spawnWorker(missing, !buildingsRendered); + const indices = []; + for (const c of completions) { + if (c.participants) { + for (const p of c.participants) indices.push(p.charIndex); + } + } + enqueueActors(indices); }); + + // Prefetch global feed so its actor thumbnails render alongside the user's own + prefetchLatestMemories(); +} + +async function prefetchLatestMemories() { + try { + const res = await fetch(`${API_URL}/api/memories/latest`); + if (!res.ok) return; + const data = await res.json(); + const entries = data.entries || []; + latestMemoriesCache.set(entries); + + const indices = []; + for (const e of entries) { + for (const p of e.participants) indices.push(p.charIndex); + } + if (indices.length > 0) enqueueActors(indices); + } catch { + // Non-critical — page will show fallback letters + } } function spawnWorker(actorIndices, includeBuildings) { - if (activeWorker) { - activeWorker.terminate(); - activeWorker = null; - } - try { const worker = new Worker( new URL('./thumbnails.worker.js', import.meta.url), @@ -72,6 +104,8 @@ function spawnWorker(actorIndices, includeBuildings) { for (const i of actorIndices) renderedActors.add(i); worker.terminate(); activeWorker = null; + // Process anything that was enqueued while we were busy + flushQueue(); break; case 'error': console.warn('[Thumbnails] Worker error:', e.data.message); diff --git a/src/lib/AccountIndicator.svelte b/src/lib/AccountIndicator.svelte index 50017a4..6dd46e3 100644 --- a/src/lib/AccountIndicator.svelte +++ b/src/lib/AccountIndicator.svelte @@ -6,7 +6,7 @@ import { API_URL } from '../core/config.js'; import { showToast } from '../core/toast.js'; import { currentPage } from '../stores.js'; - import { navigateTo, navigateToMultiplayer } from '../core/navigation.js'; + import { navigateTo, navigateToMultiplayer, navigateToLatestMemories } from '../core/navigation.js'; import SignInModal from './SignInModal.svelte'; import DiscordIcon from './icons/DiscordIcon.svelte'; @@ -134,6 +134,10 @@