← snapshot
7211 bytes
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { type ReactNode, useEffect, useState } from "react";
import { JacIcon } from "./jac-icon";
type JacTheme = "light" | "dark";
const THEME_KEY = "jac-site-theme";
function initialTheme(): JacTheme {
try {
const saved = localStorage.getItem(THEME_KEY);
if (saved === "light" || saved === "dark") return saved;
} catch {
// localStorage unavailable — fall through
}
// Dark is the design; light is the branch you have to ask for.
return "dark";
}
/** `/repos/<slug>/...` → `<slug>`, else null. */
function repoFromPath(pathname: string): string | null {
const parts = pathname.split("/").filter(Boolean);
return parts[0] === "repos" && parts[1] ? decodeURIComponent(parts[1]) : null;
}
const CRUMBS: [test: RegExp, label: string][] = [
[/^\/$/, "Repos"],
[/^\/new/, "Warping the loom"],
[/^\/registry/, "Registry"],
[/\/log$/, "Log"],
[/\/decisions$/, "Decisions"],
[/\/decisions\//, "Decision"],
[/\/promote/, "The gate"],
[/\/refs/, "Refs"],
[/\/snapshots\//, "Snapshot"],
];
function crumbFor(pathname: string): string {
for (const [test, label] of CRUMBS) if (test.test(pathname)) return label;
return "Overview";
}
/** The mill floor: rail, topbar, and the one scrolling region. */
export function JacShell({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<JacTheme>("dark");
const pathname = usePathname() || "/";
const repo = repoFromPath(pathname);
const [unsettled, setUnsettled] = useState<number | null>(null);
useEffect(() => {
setTheme(initialTheme());
}, []);
// The rail carries the inbox count, so it is visible from every screen.
// Best-effort: a failed poll simply leaves the badge off.
useEffect(() => {
if (!repo) {
setUnsettled(null);
return;
}
let live = true;
fetch(`/api/repos/${encodeURIComponent(repo)}/decisions?state=unsettled`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
if (live && d) setUnsettled(d.decisions?.length ?? 0);
})
.catch(() => {
/* rail badge is decoration; never surface this */
});
return () => {
live = false;
};
}, [repo, pathname]);
function toggleTheme() {
setTheme((current) => {
const next: JacTheme = current === "dark" ? "light" : "dark";
try {
localStorage.setItem(THEME_KEY, next);
} catch {
// non-fatal
}
return next;
});
}
const base = repo ? `/repos/${encodeURIComponent(repo)}` : "";
const isActive = (href: string, exact = false) =>
exact ? pathname === href : pathname.startsWith(href);
// The stage is the front door and brings its own chrome: no rail, no
// topbar, nothing between you and Jackie.
if (pathname === "/") {
return (
<div className="jac-site" data-jac-theme={theme}>
<div className="jac-weave" aria-hidden="true" />
{children}
</div>
);
}
return (
<div className="jac-site" data-jac-theme={theme}>
<div className="jac-weave" aria-hidden="true" />
<div className="jac-app">
<nav className="jac-rail">
<Link href="/" className="jac-rail-brand">
<span className="jac-rail-mark" aria-hidden="true" />
<span>
<span className="jac-rail-name">lowell</span>
<span className="jac-rail-sub" style={{ display: "block" }}>
mill floor
</span>
</span>
</Link>
<div className="jac-rail-section">
<div className="jac-rail-section-label">Explore</div>
<Link href="/" className="jac-nav-item" data-active={isActive("/", true)}>
<JacIcon name="repos" className="jac-nav-ico" />
Repos
</Link>
<Link
href="/registry"
className="jac-nav-item"
data-active={isActive("/registry")}
>
<JacIcon name="registry" className="jac-nav-ico" />
Registry
</Link>
<Link href="/new" className="jac-nav-item" data-active={isActive("/new")}>
<JacIcon name="loom" className="jac-nav-ico" />
Warp a loom
</Link>
</div>
{repo ? (
<div className="jac-rail-section">
<div className="jac-rail-section-label">{repo}</div>
<Link
href={base}
className="jac-nav-item"
data-active={pathname === base}
>
<JacIcon name="overview" className="jac-nav-ico" />
Overview
</Link>
<Link
href={`${base}/log`}
className="jac-nav-item"
data-active={isActive(`${base}/log`)}
>
<JacIcon name="log" className="jac-nav-ico" />
Log
</Link>
<Link
href={`${base}/decisions`}
className="jac-nav-item"
data-active={isActive(`${base}/decisions`)}
>
<JacIcon name="decisions" className="jac-nav-ico" />
Decisions
{unsettled ? (
<span className="jac-nav-badge" data-waiting="true">
{unsettled}
</span>
) : null}
</Link>
<Link
href={`${base}/promote`}
className="jac-nav-item"
data-active={isActive(`${base}/promote`)}
>
<JacIcon name="promote" className="jac-nav-ico" />
Promote
</Link>
<Link
href={`${base}/refs`}
className="jac-nav-item"
data-active={isActive(`${base}/refs`)}
>
<JacIcon name="refs" className="jac-nav-ico" />
Refs
</Link>
</div>
) : null}
<div className="jac-rail-foot">
identities are declared, not authenticated
<br />
nothing persists past the process
</div>
</nav>
<div className="jac-col">
<header className="jac-topbar">
<span className="jac-crumb">
<span className="jac-crumb-dim">
{repo ? `${repo} / ` : "lowell / "}
</span>
{crumbFor(pathname)}
</span>
<span className="jac-spacer" />
<span className="jac-small" style={{ whiteSpace: "nowrap" }}>
in-memory · process lifetime
</span>
<button
type="button"
className="jac-theme-btn"
onClick={toggleTheme}
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} theme`}
>
{theme === "dark" ? "☾" : "☀"}
</button>
</header>
<div className="jac-scroll">{children}</div>
</div>
</div>
</div>
);
}