import Link from "next/link"; import { badgeVariants, type BadgeVariants } from "@/components/ui/badge"; import BrowseHeader from "@/components/BrowseHeader"; import NewProjectMenu from "@/components/NewProjectMenu"; import CopyButton from "@/components/CopyButton"; import ProjectGrid from "@/components/projects/ProjectGrid"; import { KINDS, decisionCounts, indexHealth, listFolders, listProjects } from "@/lib/projects"; import { PROJECT_STATES } from "@/lib/project-types"; import { projectsNote } from "@/lib/dashboard"; export const dynamic = "force-dynamic"; // A STATIC segment, so it wins over app/[mode]/page.tsx -- which would // otherwise catch /browse, fail isMode(), and 404. Same trick as /mix. // // ZERO CLIENT JS, still. The four filters are links that change searchParams, // exactly as /browse/decisions does it, and `?q=` is a plain GET form carrying // the others as hidden inputs -- the /browse/find idiom -- so any filtered view // is one pasteable URL. Nothing here hydrates. // // Counts on the chips come from the UNFILTERED set on purpose. A chip whose // number changes when you click a different chip moves under the cursor, and // the whole point of a filter row is to say how much is behind each one. type Search = { kind?: string; template?: string; state?: string; open?: string; q?: string; sort?: string; }; export default async function BrowsePage({ searchParams, }: { searchParams: Promise; }) { const sp = await searchParams; const { kind, template, state, open, q, sort } = sp; const all = await listProjects(); const counts = await decisionCounts(); const folders = await listFolders(); const needle = (q ?? "").trim().toLowerCase(); const match = (p: (typeof all)[number]) => { const c = counts.get(p.id); return ( (!kind || p.kind === kind) && (!template || p.template === template) && (!state || p.state === state) && (!open || (open === "blocking" ? (c?.blocking ?? 0) > 0 : (c?.blocking ?? 0) + (c?.open ?? 0) > 0)) && (!needle || p.haystack.includes(needle)) ); }; const items = all.filter(match); const sorted = sort === "name" ? [...items].sort((a, b) => a.id.localeCompare(b.id)) : items; const nOf = (pred: (p: (typeof all)[number]) => boolean) => all.filter(pred).length; const qs = (next: Partial) => { const p = new URLSearchParams(); for (const [k, v] of Object.entries({ ...sp, ...next })) if (v) p.set(k, String(v)); const s = p.toString(); return `/browse${s ? `?${s}` : ""}`; }; // Group by folder, using the COLLAPSED labels but never collapsed URLs. const groups = new Map(); for (const p of sorted) { // Which displayed folder owns this project: the deepest collapsed node // whose path is a prefix of the project's id. let owner = ""; for (const f of folders.keys()) { if (f && (p.id === f || p.id.startsWith(`${f}/`)) && f.length > owner.length) owner = f; } const list = groups.get(owner); if (list) list.push(p); else groups.set(owner, [p]); } const blocking = [...counts.values()].reduce((n, c) => n + c.blocking, 0); const openN = [...counts.values()].reduce((n, c) => n + c.open, 0); // The index's only visible surface. It self-heals silently, so its health has // to be said deliberately or it cannot be observed at all. const ix = indexHealth(); return (
{/* --- filters, as links ---------------------------------------- */}
kind {KINDS.map((k) => ( p.kind === k.id)}`} /> ))}
state {PROJECT_STATES.filter((s) => nOf((p) => p.state === s) > 0).map((s) => ( p.state === s)}`} /> ))} decisions (counts.get(p.id)?.blocking ?? 0) > 0)}`} tone="blocking" /> (counts.get(p.id)?.blocking ?? 0) + (counts.get(p.id)?.open ?? 0) > 0, )}`} /> sort
{/* A GET form so the result is a pasteable URL, and hidden inputs so searching does not throw away the filters you already set. */}
{(["kind", "template", "state", "open", "sort"] as const).map((k) => sp[k] ? : null, )} {q && ( clear )}
({ id: k.id, label: k.label, scaffold: k.scaffold }))} />
decisions
{all.length === 0 ? (

No projects under REPORTS_DIR. A directory becomes one by holding a video.manifest.json, a{" "} spec.json, or a sweep report.

) : (
{[...groups.entries()].map(([folderPath, list]) => (
{folderPath && (

{folders.get(folderPath)?.label ?? folderPath}

)}
))}
)} {ix.ok && ix.total > 0 && (

index: {ix.fresh}/{ix.total} fresh — the filesystem is the model; this only caches what it said

)}
); } function Chip({ href, on, label, tone, }: { href: string; on: boolean; label: string; tone?: BadgeVariants["variant"]; }) { return ( {label} ); }