Skip to content

Commit cebb5c3

Browse files
committed
Merge remote-tracking branch 'origin/feat/gallery-feed-v1'
# Conflicts: # docs/status/openclaw-status.md
2 parents 6bb600a + 57fecd5 commit cebb5c3

13 files changed

Lines changed: 688 additions & 3 deletions

File tree

‎apps/api/src/routes/hostedRoutes.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,21 @@ export async function hostedRoutes(app: FastifyInstance) {
318318
}
319319
);
320320

321+
// Community gallery (P3.1 feed v1): public listing over share snapshots —
322+
// name + aggregate counters only, playable-now games, newest shares first.
323+
app.get(
324+
'/api/gallery',
325+
async (_request, reply) => {
326+
try {
327+
const games = await hostedServiceInstance!.listGallery();
328+
return { games };
329+
} catch (error: any) {
330+
reply.code(500);
331+
return { error: error.message || 'Failed to list gallery' };
332+
}
333+
}
334+
);
335+
321336
// Health check for hosted service — auto-creates hosted directory
322337
app.get(
323338
'/api/hosted/health',

‎apps/api/src/services/hostedService.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,24 @@ export const SHARE_WEB_ORIGIN = process.env.SHARE_WEB_ORIGIN || 'http://localhos
5050
/** Serialized remix payload cap (design §4): host-time rejection, no partial artifacts. */
5151
export const SHARE_PAYLOAD_MAX_BYTES = 25 * 1024 * 1024;
5252

53+
/**
54+
* Public gallery-listing projection (P3.1 feed v1): one shared game as the
55+
* community feed shows it. Aggregate integers only — zero PII by design,
56+
* same rule as the slice-3 counters.
57+
*/
58+
export interface GalleryEntry {
59+
/** Capability token — the `/share/:token` path segment. */
60+
id: string;
61+
/** Project name at share time (from the snapshot payload). */
62+
name: string;
63+
plays: number;
64+
remixes: number;
65+
/** ISO-8601 share timestamp from the snapshot payload. */
66+
sharedAt: string;
67+
/** Absolute playable URL (`HOSTED_BASE_URL/share/:token`). */
68+
url: string;
69+
}
70+
5371
/** Minimal mime inference for asset refs (same intent as assetService.getMimeType). */
5472
function inferMimeType(filename: string): string {
5573
const ext = filename.slice(filename.lastIndexOf('.') + 1).toLowerCase();
@@ -572,6 +590,59 @@ window.addEventListener('DOMContentLoaded', () => {
572590
return results;
573591
}
574592

593+
/**
594+
* Community gallery listing (P3.1 feed v1).
595+
*
596+
* Enumerates `.share.json` sidecars in HOSTED_DIR (the share-snapshot
597+
* source of truth per design §4/§5) and joins each with its `.meta.json`
598+
* for aggregate counters and expiry. A game is listed only when it is
599+
* actually playable right now:
600+
* - sidecar present and parsable (legacy shares without one are not listed);
601+
* - meta present (orphan sidecar → skip) AND the hosted html still on disk;
602+
* - not expired (expired shares are excluded — same rule as serving).
603+
*
604+
* Public projection only: name, aggregate integers, sharedAt, token URL.
605+
* Sorted by sharedAt desc (newest shares first).
606+
*/
607+
async listGallery(): Promise<GalleryEntry[]> {
608+
const hostedDir = await this.ensureHostedDir();
609+
let files: string[];
610+
try {
611+
files = await readdir(hostedDir);
612+
} catch {
613+
return [];
614+
}
615+
616+
const entries: GalleryEntry[] = [];
617+
for (const file of files) {
618+
if (!file.endsWith('.share.json')) continue;
619+
const hostedId = file.slice(0, -'.share.json'.length);
620+
try {
621+
const payload = JSON.parse(await readFile(join(hostedDir, file), 'utf-8')) as ShareRemixPayload;
622+
if (!payload || typeof payload.sharedAt !== 'string') continue;
623+
624+
const meta = await this.getHostedExport(hostedId);
625+
if (!meta) continue; // orphan sidecar — nothing playable behind it
626+
if (meta.expiresAt && new Date(meta.expiresAt) < new Date()) continue;
627+
if (!existsSync(join(hostedDir, `${hostedId}.html`))) continue;
628+
629+
entries.push({
630+
id: hostedId,
631+
name: payload.project?.name || meta.projectName || 'Untitled Game',
632+
plays: Math.max(0, Math.trunc(meta.counts?.plays ?? 0)),
633+
remixes: Math.max(0, Math.trunc(meta.counts?.remixes ?? 0)),
634+
sharedAt: payload.sharedAt,
635+
url: this.buildHostedUrl(hostedId),
636+
});
637+
} catch (err) {
638+
this.logger.warn({ file, err }, 'Failed to read share payload for gallery; skipping');
639+
}
640+
}
641+
642+
entries.sort((a, b) => new Date(b.sharedAt).getTime() - new Date(a.sharedAt).getTime());
643+
return entries;
644+
}
645+
575646
/**
576647
* Delete hosted export
577648
*/

‎apps/api/src/test/gallery.test.ts‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* Route tests for GET /api/gallery (P3.1 community feed v1):
3+
*
4+
* - empty state on a fresh HOSTED_DIR
5+
* - listing over .share.json snapshots joined with .meta.json counters
6+
* - sorted by sharedAt desc
7+
* - expired shares excluded; non-expiring kept
8+
* - legacy shares without a sidecar are not listed
9+
*
10+
* Fixtures are real: shares created through the actual POST /share route into
11+
* the tempdir HOSTED_DIR (src/test/setup.ts), then per-test artifact surgery
12+
* for deterministic ordering/expiry. No network, no mocks of code under test.
13+
*/
14+
import { describe, expect, it, beforeEach } from 'vitest';
15+
import Fastify, { type FastifyInstance } from 'fastify';
16+
import { readdir, readFile, writeFile, rm } from 'node:fs/promises';
17+
import { join } from 'node:path';
18+
import { hostedRoutes } from '../routes/hostedRoutes';
19+
import { ProjectService } from '../services/projectService';
20+
21+
const mockLogger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any;
22+
const HOSTED_DIR = process.env.HOSTED_DIR as string;
23+
24+
async function buildApp() {
25+
const app = Fastify({ logger: false });
26+
await app.register(hostedRoutes);
27+
await app.ready();
28+
return app;
29+
}
30+
31+
async function createAndShare(name: string) {
32+
const app = await buildApp();
33+
const projects = new ProjectService(mockLogger);
34+
const { id } = await projects.createProject({ name, genre: 'platformer', artStyle: 'pixel' });
35+
const res = await app.inject({ method: 'POST', url: `/api/projects/${id}/share` });
36+
expect(res.statusCode).toBe(201);
37+
const token = res.json().hosted.id as string;
38+
return { app, token };
39+
}
40+
41+
async function getGallery(app: FastifyInstance) {
42+
const res = await app.inject({ method: 'GET', url: '/api/gallery' });
43+
expect(res.statusCode).toBe(200);
44+
return res.json().games as Array<{
45+
id: string;
46+
name: string;
47+
plays: number;
48+
remixes: number;
49+
sharedAt: string;
50+
url: string;
51+
}>;
52+
}
53+
54+
/** Rewrite one share's snapshot timestamp + meta expiry for deterministic fixtures. */
55+
async function redateShare(token: string, opts: { sharedAt?: string; expiresAt?: string | null }) {
56+
if (opts.sharedAt !== undefined) {
57+
const sharePath = join(HOSTED_DIR, `${token}.share.json`);
58+
const payload = JSON.parse(await readFile(sharePath, 'utf-8'));
59+
payload.sharedAt = opts.sharedAt;
60+
await writeFile(sharePath, JSON.stringify(payload, null, 2));
61+
}
62+
if (opts.expiresAt !== undefined) {
63+
const metaPath = join(HOSTED_DIR, `${token}.meta.json`);
64+
const meta = JSON.parse(await readFile(metaPath, 'utf-8'));
65+
meta.expiresAt = opts.expiresAt;
66+
await writeFile(metaPath, JSON.stringify(meta, null, 2));
67+
}
68+
}
69+
70+
/** Tests share one tempdir HOSTED_DIR — wipe artifacts between tests. */
71+
beforeEach(async () => {
72+
const entries = await readdir(HOSTED_DIR).catch(() => [] as string[]);
73+
await Promise.all(entries.map((e) => rm(join(HOSTED_DIR, e), { recursive: true, force: true })));
74+
});
75+
76+
describe('GET /api/gallery — community feed v1', () => {
77+
it('returns an empty list on a fresh hosted dir', async () => {
78+
const app = await buildApp();
79+
const games = await getGallery(app);
80+
expect(games).toEqual([]);
81+
});
82+
83+
it('lists shared games with name, aggregate counts, sharedAt and playable URL', async () => {
84+
const { app, token } = await createAndShare('Gallery Fixture Alpha');
85+
// One play + one remix recorded through the real counter path.
86+
await app.inject({ method: 'GET', url: `/share/${token}` }); // serve → plays+1
87+
await app.inject({ method: 'GET', url: `/api/share/${token}/remix` }); // payload → remixes+1
88+
89+
const games = await getGallery(app);
90+
expect(games).toHaveLength(1);
91+
const game = games[0];
92+
expect(game.id).toBe(token);
93+
expect(game.name).toBe('Gallery Fixture Alpha');
94+
expect(game.plays).toBe(1);
95+
expect(game.remixes).toBe(1);
96+
expect(typeof game.sharedAt).toBe('string');
97+
expect(game.url).toContain(`/share/${token}`);
98+
});
99+
100+
it('sorts by sharedAt descending regardless of share order on disk', async () => {
101+
const a = await createAndShare('Gallery Order A');
102+
const b = await createAndShare('Gallery Order B');
103+
const c = await createAndShare('Gallery Order C');
104+
// Make on-disk share order oldest-first-newest-last but timestamps say C > A > B.
105+
await redateShare(a.token, { sharedAt: '2026-08-26T10:00:00.000Z' });
106+
await redateShare(b.token, { sharedAt: '2026-08-26T09:00:00.000Z' });
107+
await redateShare(c.token, { sharedAt: '2026-08-26T11:00:00.000Z' });
108+
void a.app;
109+
const app = c.app;
110+
111+
const games = await getGallery(app);
112+
expect(games.map((g) => g.name)).toEqual(['Gallery Order C', 'Gallery Order A', 'Gallery Order B']);
113+
});
114+
115+
it('excludes expired shares but keeps non-expiring ones', async () => {
116+
const live = await createAndShare('Gallery Live');
117+
const dead = await createAndShare('Gallery Expired');
118+
await redateShare(dead.token, { expiresAt: '2020-01-01T00:00:00.000Z' });
119+
void live.app;
120+
121+
const games = await getGallery(live.app);
122+
expect(games.map((g) => g.name)).toEqual(['Gallery Live']);
123+
expect(games.some((g) => g.id === dead.token)).toBe(false);
124+
});
125+
126+
it('does not list legacy shares without a .share.json sidecar', async () => {
127+
const { app, token } = await createAndShare('Gallery Legacy');
128+
// Strip the sidecar → pre-slice-2 share; still playable, not gallery-worthy.
129+
const { unlink } = await import('node:fs/promises');
130+
await unlink(join(HOSTED_DIR, `${token}.share.json`));
131+
132+
const games = await getGallery(app);
133+
expect(games).toEqual([]);
134+
});
135+
});

‎apps/web/src/App.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { DashboardPage } from './pages/DashboardPage';
1313
import { CreateProjectPage } from './pages/CreateProjectPage';
1414
import { OpenProjectPage } from './pages/OpenProjectPage';
1515
import { ExamplesPage } from './pages/ExamplesPage';
16+
import { GalleryPage } from './pages/GalleryPage';
1617
import { SettingsPage } from './pages/SettingsPage';
1718

1819
// Import CSS files
@@ -105,6 +106,7 @@ function App() {
105106
<Route path="create-project" element={<CreateProjectPage />} />
106107
<Route path="open-project" element={<OpenProjectPage />} />
107108
<Route path="examples" element={<ExamplesPage />} />
109+
<Route path="gallery" element={<GalleryPage />} />
108110
<Route path="settings" element={<SettingsPage />} />
109111
<Route path="settings/ai-providers" element={<Suspense fallback={<PageLoader />}><AIProvidersPage /></Suspense>} />
110112
{/* Project routes — nested under ProjectPage so Outlet works */}

‎apps/web/src/api/client.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import type {
3333
HostedExport,
3434
HostedOptions,
3535
ShareResponse,
36+
GalleryGame,
3637
RemixPayload,
3738
GenerationResult,
3839
GenerationQuality,
@@ -72,6 +73,7 @@ export type {
7273
GenerationResult,
7374
GenerationQuality,
7475
GenerationFormat,
76+
GalleryGame,
7577
GenerationAspectRatio
7678
} from './types';
7779

@@ -486,6 +488,11 @@ export const api = {
486488
getRemixPayload: (token: string) =>
487489
request<RemixPayload>(`/api/share/${encodeURIComponent(token)}/remix`),
488490

491+
// Community gallery (P3.1 feed v1): public listing of playable shares,
492+
// newest first. Cards deep-link to the API-origin /share/:token page.
493+
listGallery: () =>
494+
request<{ games: GalleryGame[] }>('/api/gallery').then((r) => r.games || []),
495+
489496
// Share stats (slice 3): aggregate integers only, consumed by the Settings
490497
// Local diagnostics readout for tokens known from the local funnel log.
491498
getShareStats: (token: string) =>

‎apps/web/src/api/types.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,18 @@ export interface ShareResponse {
383383
error?: string;
384384
}
385385

386+
/** One shared game in the community gallery (P3.1 feed v1, GET /api/gallery). */
387+
export interface GalleryGame {
388+
/** Capability token — the `/share/:token` path segment. */
389+
id: string;
390+
name: string;
391+
plays: number;
392+
remixes: number;
393+
sharedAt: string;
394+
/** Absolute playable URL on the API origin. */
395+
url: string;
396+
}
397+
386398
/** Lightweight asset reference in a remix payload (no embedded data). */
387399
export interface RemixAssetRef {
388400
id: string;

‎apps/web/src/constants/sidebar.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Home, Settings } from 'lucide-react';
1+
import { Home, Settings, LayoutGrid } from 'lucide-react';
22
import type { LucideIcon } from 'lucide-react';
33

44
export interface SidebarItem {
@@ -18,6 +18,11 @@ export const sidebarItems: SidebarItem[] = [
1818
label: 'Dashboard',
1919
icon: Home,
2020
},
21+
{
22+
path: '/gallery',
23+
label: 'Gallery',
24+
icon: LayoutGrid,
25+
},
2126
{
2227
path: '/settings',
2328
label: 'Settings',

‎apps/web/src/pages/DashboardPage.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useState, useEffect } from 'react';
22
import { Link, useNavigate } from 'react-router-dom';
3-
import { PlusCircle, FolderOpen, BookOpen, Sparkles, Terminal, Zap } from 'lucide-react';
3+
import { PlusCircle, FolderOpen, BookOpen, Sparkles, Terminal, Zap, LayoutGrid } from 'lucide-react';
44
import { api, type ProjectListItem } from '../api/client';
55

66
// Helper function to safely format dates
@@ -130,6 +130,11 @@ export function DashboardPage() {
130130
<h3 className="action-title">Examples</h3>
131131
<p className="action-description">Browse sample game templates</p>
132132
</Link>
133+
<Link to="/gallery" className="action-card">
134+
<LayoutGrid size={24} className="action-icon-svg" />
135+
<h3 className="action-title">Community Gallery</h3>
136+
<p className="action-description">Play and remix games shared by creators</p>
137+
</Link>
133138
</div>
134139
</section>
135140

0 commit comments

Comments
 (0)