jacquardSnapshot

← snapshot

7802 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"],
  [/^\/import/, "Import"],
  [/\/log$/, "Log"],
  [/\/decisions$/, "Decisions"],
  [/\/decisions\//, "Decision"],
  [/\/promote/, "The gate"],
  [/\/refs/, "Refs"],
  [/\/weave$/, "Weave"],
  [/\/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="/import"
              className="jac-nav-item"
              data-active={isActive("/import")}
            >
              <JacIcon name="registry" className="jac-nav-ico" />
              Import
            </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>
              <Link
                href={`${base}/weave`}
                className="jac-nav-item"
                data-active={isActive(`${base}/weave`)}
              >
                <JacIcon name="overview" className="jac-nav-ico" />
                Weave
              </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>
  );
}