import { readdir, stat } from 'node:fs/promises'; import type { Dirent } from 'node:fs'; import { join } from 'node:path'; /** * Mission Control project detail page — a generated gallery for ONE project. * * Every project card links here (`/project//`), so a project is * always openable regardless of whether it has a polished preview/review.html * surface. The page recursively scans the project dir for its rendered videos * and images and renders them as a playable gallery; media is streamed through * the existing `/m//` route (Range-aware, root-allowlisted). */ const VIDEO_EXTS = new Set(['.mp4', '.mov', '.webm']); const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif']); /** Working/internal dirs that never hold viewable deliverables. */ const SKIP_DIRS = new Set(['history', 'state', '.vclaw-jobs', 'node_modules', 'checkpoints', 'events', 'artifacts']); const MAX_VIDEOS = 80; const MAX_IMAGES = 160; const MAX_DEPTH = 6; /** Hide sub-KB scratch images (diagnostic frames), keep real stills. */ const MIN_IMAGE_BYTES = 4 * 1024; interface MediaItem { /** Path RELATIVE to the workspace root (so `/m//` resolves it). */ rel: string; name: string; mtimeMs: number; sizeBytes: number; } const MAX_HTML = 30; /** Friendly labels for the common per-project review/preview/status pages. */ const PAGE_LABELS: Record = { 'preview.html': 'Preview', 'review.html': 'Review', 'client-review.html': 'Client Review', 'edit.html': 'Editor', 'run-status.html': 'Run Status', 'run.html': 'Run', 'index.html': 'Index', }; function pageLabel(name: string): string { return PAGE_LABELS[name] ?? name.replace(/\.html$/i, '').replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } function esc(s: string): string { return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!)); } /** Encode each path segment for a `/m/` or `/p/` URL (keeps the slashes). */ function segEncode(rel: string): string { return rel.split('/').map(encodeURIComponent).join('/'); } async function walk(dir: string, rootRel: string, depth: number, vids: MediaItem[], imgs: MediaItem[], htmls: MediaItem[]): Promise { if (depth > MAX_DEPTH) return; const entries: Dirent[] = await readdir(dir, { withFileTypes: true }).catch(() => []); for (const e of entries) { if (e.name.startsWith('.')) continue; const full = join(dir, e.name); const rel = `${rootRel}/${e.name}`; if (e.isDirectory()) { if (SKIP_DIRS.has(e.name)) continue; await walk(full, rel, depth + 1, vids, imgs, htmls); continue; } if (!e.isFile()) continue; const dot = e.name.lastIndexOf('.'); const ext = dot >= 0 ? e.name.slice(dot).toLowerCase() : ''; if (ext === '.html' && htmls.length < MAX_HTML) { const st = await stat(full).catch(() => null); if (st) htmls.push({ rel, name: e.name, mtimeMs: st.mtimeMs, sizeBytes: st.size }); } else if (VIDEO_EXTS.has(ext) && vids.length < MAX_VIDEOS) { const st = await stat(full).catch(() => null); if (st) vids.push({ rel, name: e.name, mtimeMs: st.mtimeMs, sizeBytes: st.size }); } else if (IMAGE_EXTS.has(ext) && imgs.length < MAX_IMAGES) { const st = await stat(full).catch(() => null); if (st && st.size > MIN_IMAGE_BYTES) imgs.push({ rel, name: e.name, mtimeMs: st.mtimeMs, sizeBytes: st.size }); } } } /** `final/` deliverables first, then newest. */ function order(a: MediaItem, b: MediaItem): number { const rank = (m: MediaItem) => (/(^|\/)final\//.test(m.rel) ? 0 : 1); return rank(a) - rank(b) || b.mtimeMs - a.mtimeMs; } /** * Render the project detail gallery HTML. `root` must be an allowlisted * workspace root and `slug` a validated project slug (the caller — serve.ts — * enforces both before calling). Returns a self-contained HTML document. * * `opts.mediaUrl` / `opts.pageUrl` make the media/page URL builders injectable. * Omitted (the monitor server's case) they default to the server routes * `/m//` and `/p//` — byte-identical to before. A * persisted, file://-openable gallery injects absolute `file://` builders * instead (see preview-portal/gallery.ts). */ export async function renderProjectDetail( root: string, slug: string, opts: { displayName?: string; mediaUrl?: (rel: string) => string; pageUrl?: (rel: string) => string } = {}, ): Promise { const displayName = opts.displayName ?? slug; const projectDir = join(root, 'projects', slug); const vids: MediaItem[] = []; const imgs: MediaItem[] = []; const htmls: MediaItem[] = []; await walk(projectDir, `projects/${slug}`, 0, vids, imgs, htmls); vids.sort(order); imgs.sort(order); // Pages: every per-project review/preview/status HTML we generated. Well-known // ones first (preview, review, client-review, run-status…), then the rest A→Z. const pageRank = (h: MediaItem) => { const i = Object.keys(PAGE_LABELS).indexOf(h.name); return i < 0 ? 99 : i; }; htmls.sort((a, b) => pageRank(a) - pageRank(b) || a.rel.localeCompare(b.rel)); const m = opts.mediaUrl ?? ((rel: string) => `/m/${encodeURIComponent(root)}/${segEncode(rel)}`); const p = opts.pageUrl ?? ((rel: string) => `/p/${encodeURIComponent(root)}/${segEncode(rel)}`); const pageCards = htmls .map((h) => `${esc(pageLabel(h.name))}${esc(h.rel.replace(`projects/${slug}/`, ''))}`) .join('\n'); const videoCards = vids .map( (v) => `
${esc(v.name)}
`, ) .join('\n'); const imageCards = imgs .map( (i) => ` ${esc(i.name)} `, ) .join('\n'); const empty = vids.length === 0 && imgs.length === 0 && htmls.length === 0 ? `

No pages, videos, or images found in this project yet.

` : ''; return ` ${esc(displayName)} — VideoClaw
← Mission Control

${esc(displayName)}

${htmls.length ? `${htmls.length} page${htmls.length === 1 ? '' : 's'} · ` : ''}${vids.length} video${vids.length === 1 ? '' : 's'} · ${imgs.length} image${imgs.length === 1 ? '' : 's'}
${empty} ${htmls.length ? `

Pages

${pageCards}
` : ''} ${vids.length ? `

Videos

${videoCards}
` : ''} ${imgs.length ? `

Images

${imageCards}
` : ''}
`; }