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 @@