From 6716f73bedb859aaa64f6262ac9c7eb8ec6b83ac Mon Sep 17 00:00:00 2001 From: pulk17 Date: Mon, 14 Sep 2026 12:59:51 +0530 Subject: [PATCH] Web-Shell Sidebar, command palette and the router that ties the pages together. The console runs from here. --- web/src/components/CommandPalette.tsx | 176 ++++++++++++++++++++ web/src/components/layout/AppShell.tsx | 219 +++++++++++++++++++++++++ web/src/main.tsx | 19 +++ web/src/router.tsx | 149 +++++++++++++++++ 4 files changed, 563 insertions(+) create mode 100644 web/src/components/CommandPalette.tsx create mode 100644 web/src/components/layout/AppShell.tsx create mode 100644 web/src/main.tsx create mode 100644 web/src/router.tsx diff --git a/web/src/components/CommandPalette.tsx b/web/src/components/CommandPalette.tsx new file mode 100644 index 000000000..61eb729ec --- /dev/null +++ b/web/src/components/CommandPalette.tsx @@ -0,0 +1,176 @@ +import { useNavigate } from "@tanstack/react-router"; +import { Activity, FileVideo, FlaskConical, Gauge, Home, Search } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { useRegressionTests, useSamples } from "@/lib/api"; +import { cn } from "@/lib/utils"; + +interface Item { + id: string; + icon: React.ReactNode; + title: string; + subtitle?: string; + go: () => void; +} + +/** Real ⌘K palette: pages, regression tests, samples — keyboard-first. */ +export function CommandPalette() { + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [cursor, setCursor] = useState(0); + const navigate = useNavigate(); + const inputRef = useRef(null); + const { data: tests = [] } = useRegressionTests(); + const { data: samples = [] } = useSamples(); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setOpen((o) => !o); + setQ(""); + setCursor(0); + } + if (e.key === "Escape") setOpen(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + useEffect(() => { + if (open) setTimeout(() => inputRef.current?.focus(), 30); + }, [open]); + + const items = useMemo(() => { + const needle = q.trim().toLowerCase(); + const pages: Item[] = [ + { id: "p-home", icon: , title: "Home — triage inbox", go: () => navigate({ to: "/" }) }, + { id: "p-runs", icon: , title: "Test results", go: () => navigate({ to: "/runs" }) }, + { id: "p-tests", icon: , title: "Regression tests", go: () => navigate({ to: "/tests" }) }, + { id: "p-samples", icon: , title: "Samples", go: () => navigate({ to: "/samples" }) }, + { id: "p-status", icon: , title: "Platform status", go: () => navigate({ to: "/status" }) }, + ]; + if (!needle) return pages; + + const testHits: Item[] = tests + .filter((t) => `#${t.id} ${t.command} ${t.sample_name}`.toLowerCase().includes(needle)) + .slice(0, 6) + .map((t) => ({ + id: `t-${t.id}`, + icon: , + title: `#${t.id} ${t.command}`, + subtitle: t.sample_name, + go: () => navigate({ to: "/tests", search: { t: t.id } }), + })); + const sampleHits: Item[] = samples + .filter((s) => `${s.original_name} ${s.sha}`.toLowerCase().includes(needle)) + .slice(0, 4) + .map((s) => ({ + id: `s-${s.id}`, + icon: , + title: s.original_name, + subtitle: s.extension, + go: () => navigate({ to: "/samples" }), + })); + return [ + ...pages.filter((p) => p.title.toLowerCase().includes(needle)), + ...testHits, + ...sampleHits, + ]; + }, [q, tests, samples, navigate]); + + const pick = (item: Item) => { + item.go(); + setOpen(false); + }; + + return ( + <> + + + + {open && ( + setOpen(false)} + > + e.stopPropagation()} + > +
+ + { + setQ(e.target.value); + setCursor(0); + }} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setCursor((c) => Math.min(c + 1, items.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setCursor((c) => Math.max(c - 1, 0)); + } else if (e.key === "Enter" && items[cursor]) { + pick(items[cursor]); + } + }} + /> +
+
+ {items.map((item, i) => ( + + ))} + {items.length === 0 && ( +
No matches.
+ )} +
+
+ ↑↓ navigate + ↵ open + esc close +
+
+
+ )} +
+ + ); +} diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx new file mode 100644 index 000000000..4f08d409e --- /dev/null +++ b/web/src/components/layout/AppShell.tsx @@ -0,0 +1,219 @@ +import { Link, Outlet, useRouterState } from "@tanstack/react-router"; +import { + Activity, + FileVideo, + FlaskConical, + Gauge, + Inbox, + LogOut, + Moon, + Search, + Settings, + Sun, + UploadCloud, +} from "lucide-react"; +import { motion } from "motion/react"; +import { useEffect, useState } from "react"; + +import { CommandPalette } from "@/components/CommandPalette"; +import { Login } from "@/components/Login"; +import { SESSION_CHANGED, getSession, logout } from "@/lib/auth"; +import { asset, cn } from "@/lib/utils"; + +/* Classic-site menu names so migrating devs feel at home. */ +const sections = [ + { + id: "main", + label: null, + items: [ + { to: "/", label: "Home", icon: Inbox }, + { to: "/runs", label: "Test results", icon: Activity }, + ], + }, + { + id: "suite", + label: "Suite", + items: [ + { to: "/tests", label: "Regression tests", icon: FlaskConical }, + { to: "/samples", label: "Samples", icon: FileVideo }, + { to: "/upload", label: "Sample upload", icon: UploadCloud }, + ], + }, + { + id: "platform", + label: "Platform", + items: [ + { to: "/status", label: "Platform status", icon: Gauge }, + { to: "/admin", label: "Administration", icon: Settings }, + ], + }, +] as const; + +function useTheme() { + const [dark, setDark] = useState(() => localStorage.getItem("sp-theme") === "dark"); + useEffect(() => { + document.documentElement.classList.toggle("dark", dark); + localStorage.setItem("sp-theme", dark ? "dark" : "light"); + }, [dark]); + return { dark, toggle: () => setDark((d) => !d) }; +} + +const COLLAPSED = 52; +const EXPANDED = 224; + +export function AppShell() { + const { dark, toggle } = useTheme(); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const [session, setSession] = useState(getSession); + const [open, setOpen] = useState(false); + + // The account page can change the email shown here, so pick the stored + // session back up instead of leaving the corner reading the old one. + useEffect(() => { + const sync = () => setSession(getSession()); + window.addEventListener(SESSION_CHANGED, sync); + return () => window.removeEventListener(SESSION_CHANGED, sync); + }, []); + + // The reset screen arrives from an email with nobody signed in, so it + // renders on its own rather than behind the sign-in gate. + if (pathname === "/reset") return ; + if (!session) return setSession(getSession())} />; + + return ( +
+ {/* Rail reserves collapsed width; sidebar overlays on hover so main never reflows. */} +
+ + setOpen(true)} + onMouseLeave={() => setOpen(false)} + initial={false} + animate={{ width: open ? EXPANDED : COLLAPSED }} + transition={{ type: "spring", stiffness: 420, damping: 38 }} + className={cn( + "absolute inset-y-2 left-2 z-30 flex flex-col overflow-hidden rounded-xl", + open && "border bg-card shadow-pop", + )} + > +
+ CCExtractor + + Sample Platform + + {open && ( + + )} +
+ +
+ {open ? ( + + ) : ( +
+ +
+ )} +
+ + + +
+ {/* The whole identity block is the way to the account page — the + classic site puts it behind the same name in the corner. */} + +
+ {session.email.slice(0, 2)} +
+ {open && ( +
+
{session.email}
+
{session.role}
+
+ )} + + {open && ( + + )} +
+
+ +
+ + + +
+
+ ); +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 000000000..30c9f43c5 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,19 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { RouterProvider } from "@tanstack/react-router"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { router } from "@/router"; +import "./index.css"; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } }, +}); + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/web/src/router.tsx b/web/src/router.tsx new file mode 100644 index 000000000..e1cf931f1 --- /dev/null +++ b/web/src/router.tsx @@ -0,0 +1,149 @@ +import { + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router"; + +import { AppShell } from "@/components/layout/AppShell"; +import { ResetPassword } from "@/components/ResetPassword"; +import { Account } from "@/pages/Account"; +import { Admin } from "@/pages/Admin"; +import { RunDetail } from "@/pages/RunDetail"; +import { RunNew } from "@/pages/RunNew"; +import { Runs } from "@/pages/Runs"; +import { Samples } from "@/pages/Samples"; +import { Status } from "@/pages/Status"; +import { TestBuilder } from "@/pages/TestBuilder"; +import { Triage } from "@/pages/Triage"; +import { Upload } from "@/pages/Upload"; +import { Workspace } from "@/pages/Workspace"; +import { canManage, getSession } from "@/lib/auth"; + +const rootRoute = createRootRoute({ component: AppShell }); + +const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: Triage, +}); + +const runsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/runs", + component: Runs, +}); + +const runNewRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/runs/new", + component: RunNew, + // ?test= scopes the run to a single regression test (the builder's + // "queue verification run" entry point). + validateSearch: (search: Record): { test?: number } => ({ + test: search.test ? Number(search.test) : undefined, + }), +}); + +const runDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/runs/$runId", + component: RunDetail, +}); + +const testsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/tests", + component: Workspace, + validateSearch: (search: Record): { t?: number } => ({ + t: search.t ? Number(search.t) : undefined, + }), +}); + +function AdminOnly({ children }: Readonly<{ children: React.ReactNode }>) { + if (!canManage(getSession())) { + return ( +
+

Administrators only

+

+ Creating or removing regression tests is restricted to admins and contributors. +

+
+ ); + } + return <>{children}; +} + +const testBuilderRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/tests/new", + component: () => ( + + + + ), +}); + +const samplesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/samples", + component: Samples, +}); + +const uploadRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/upload", + component: Upload, +}); + +const statusRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/status", + component: Status, +}); + +// Reached from a recovery email, so it must render with no session. The +// shell hands this path straight through. +const resetRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/reset", + component: ResetPassword, +}); + +const accountRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/account", + component: Account, +}); + +const adminRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/admin", + component: () => ( + + + + ), +}); + +const routeTree = rootRoute.addChildren([ + homeRoute, + runsRoute, + runNewRoute, + runDetailRoute, + testsRoute, + testBuilderRoute, + uploadRoute, + samplesRoute, + statusRoute, + resetRoute, + accountRoute, + adminRoute, +]); + +export const router = createRouter({ routeTree }); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +}