271 lines · 10.3 KB · TypeScript
RawDownload
import { Link, Outlet, useLocation } from "@tanstack/react-router";import { useEffect, useRef, useState } from "react";import { useAtomRefresh, useAtomValue, Result } from "@effect-atom/atom-react";import { sourcesAtom, toolsAtom } from "@executor/react/api/atoms";import { useScope } from "@executor/react/api/scope-context";import { Button } from "@executor/react/components/button";import { AUTH_PATHS } from "../auth/api";import { useAuth } from "./auth"; // ── NavItem ────────────────────────────────────────────────────────────── function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) {  return (    <Link      to={props.to}      onClick={props.onNavigate}      className={[        "flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[13px] transition-colors",        props.active          ? "bg-sidebar-active text-foreground font-medium"          : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",      ].join(" ")}    >      {props.label}    </Link>  );} // ── SourceList ─────────────────────────────────────────────────────────── function SourceList(props: { pathname: string; onNavigate?: () => void }) {  const scopeId = useScope();  const sources = useAtomValue(sourcesAtom(scopeId));   return Result.match(sources, {    onInitial: () => (      <div className="px-2.5 py-2 text-[11px] text-muted-foreground/40">Loading…</div>    ),    onFailure: () => (      <div className="px-2.5 py-2 text-[11px] text-muted-foreground/40">No sources yet</div>    ),    onSuccess: ({ value }) =>      value.length === 0 ? (        <div className="px-2.5 py-2 text-[11px] leading-relaxed text-muted-foreground/40">          No sources yet        </div>      ) : (        <div className="flex flex-col gap-px">          {value.map((s) => {            const detailPath = `/sources/${s.id}`;            const active =              props.pathname === detailPath ||              props.pathname.startsWith(`${detailPath}/`);            return (              <Link                key={s.id}                to="/sources/$namespace"                params={{ namespace: s.id }}                onClick={props.onNavigate}                className={[                  "group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors",                  active                    ? "bg-sidebar-active text-foreground font-medium"                    : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",                ].join(" ")}              >                <span className="flex-1 truncate">{s.name}</span>                <span className="rounded bg-secondary/50 px-1 py-px text-[9px] font-medium text-muted-foreground/50">                  {s.kind}                </span>              </Link>            );          })}        </div>      ),  });} // ── UserFooter ────────────────────────────────────────────────────────── function UserFooter() {  const auth = useAuth();  if (auth.status !== "authenticated") return null;   const initials = auth.user.name    ? auth.user.name.split(" ").map((n) => n[0]).join("").slice(0, 2).toUpperCase()    : auth.user.email[0]!.toUpperCase();   return (    <div className="shrink-0 border-t border-sidebar-border px-3 py-2.5">      <div className="flex items-center gap-2.5">        {auth.user.avatarUrl ? (          <img            src={auth.user.avatarUrl}            alt=""            className="size-7 shrink-0 rounded-full"          />        ) : (          <div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[10px] font-semibold text-primary">            {initials}          </div>        )}        <div className="min-w-0 flex-1">          <p className="truncate text-[12px] font-medium text-foreground">            {auth.user.name ?? auth.user.email}          </p>          {auth.team && (            <p className="truncate text-[10px] text-muted-foreground">              {auth.team.name}            </p>          )}        </div>        <form action={AUTH_PATHS.logout} method="post">          <button            type="submit"            className="shrink-0 rounded-md p-1 text-muted-foreground/50 transition-colors hover:bg-sidebar-active hover:text-foreground"            title="Sign out"          >            <svg viewBox="0 0 16 16" fill="none" className="size-3.5">              <path d="M6 2H3.5A1.5 1.5 0 002 3.5v9A1.5 1.5 0 003.5 14H6M10.5 11.5L14 8l-3.5-3.5M14 8H6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />            </svg>          </button>        </form>      </div>    </div>  );} // ── SidebarContent ─────────────────────────────────────────────────────── function SidebarContent(props: {  pathname: string;  onNavigate?: () => void;  showBrand?: boolean;}) {  const isHome = props.pathname === "/";  const isSecrets = props.pathname === "/secrets";   return (    <>      {props.showBrand !== false && (        <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4">          <Link to="/" className="flex items-center gap-1.5">            <span className="font-display text-base tracking-tight text-foreground">              executor            </span>          </Link>        </div>      )}       <nav className="flex flex-1 flex-col overflow-y-auto p-2">        <NavItem          to="/"          label="Sources"          active={isHome}          onNavigate={props.onNavigate}        />        <NavItem          to="/secrets"          label="Secrets"          active={isSecrets}          onNavigate={props.onNavigate}        />         <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50">          <span>Configured</span>        </div>         <SourceList pathname={props.pathname} onNavigate={props.onNavigate} />      </nav>       <UserFooter />    </>  );} // ── Shell ───────────────────────────────────────────────────────────────── export function Shell() {  const location = useLocation();  const pathname = location.pathname;  const scopeId = useScope();  const refreshSources = useAtomRefresh(sourcesAtom(scopeId));  const refreshTools = useAtomRefresh(toolsAtom(scopeId));  const lastPathname = useRef(pathname);  const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);  if (lastPathname.current !== pathname) {    lastPathname.current = pathname;    if (mobileSidebarOpen) setMobileSidebarOpen(false);  }   // Lock scroll when mobile sidebar open  useEffect(() => {    if (!mobileSidebarOpen) return;    const prev = document.body.style.overflow;    document.body.style.overflow = "hidden";    return () => {      document.body.style.overflow = prev;    };  }, [mobileSidebarOpen]);   return (    <div className="flex h-screen overflow-hidden">      {/* Desktop sidebar */}      <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">        <SidebarContent pathname={pathname} />      </aside>       {/* Mobile sidebar overlay */}      {mobileSidebarOpen && (        <div className="fixed inset-0 z-50 flex md:hidden">          <button            type="button"            aria-label="Close navigation"            className="absolute inset-0 bg-black/45 backdrop-blur-[1px]"            onClick={() => setMobileSidebarOpen(false)}          />          <div className="relative flex h-full w-[84vw] max-w-xs flex-col border-r border-sidebar-border bg-sidebar shadow-2xl">            <div className="flex h-12 shrink-0 items-center justify-between border-b border-sidebar-border px-4">              <Link to="/" className="flex items-center gap-1.5">                <span className="font-display text-base tracking-tight text-foreground">                  executor                </span>              </Link>              <button                type="button"                aria-label="Close navigation"                onClick={() => setMobileSidebarOpen(false)}                className="size-8 flex items-center justify-center rounded-md text-sidebar-foreground hover:bg-sidebar-active hover:text-foreground"              >                <svg viewBox="0 0 16 16" className="size-3.5">                  <path d="M3 3l10 10M13 3L3 13" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />                </svg>              </button>            </div>            <SidebarContent              pathname={pathname}              onNavigate={() => setMobileSidebarOpen(false)}              showBrand={false}            />          </div>        </div>      )}       {/* Main content */}      <main className="flex min-h-0 flex-1 flex-col min-w-0 overflow-hidden">        {/* Mobile top bar */}        <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-4 md:hidden">          <button            type="button"            aria-label="Open navigation"            onClick={() => setMobileSidebarOpen(true)}            className="size-8 flex items-center justify-center rounded-md border border-border bg-card hover:bg-accent/50"          >            <svg viewBox="0 0 16 16" className="size-4">              <path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />            </svg>          </button>          <Link to="/" className="flex items-center gap-1.5">            <span className="font-display text-base tracking-tight text-foreground">              executor            </span>          </Link>          <div className="w-8 shrink-0" />        </div>         <Outlet />      </main>    </div>  );}