Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(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<Item[]>(() => {
const needle = q.trim().toLowerCase();
const pages: Item[] = [
{ id: "p-home", icon: <Home className="size-3.5" />, title: "Home — triage inbox", go: () => navigate({ to: "/" }) },
{ id: "p-runs", icon: <Activity className="size-3.5" />, title: "Test results", go: () => navigate({ to: "/runs" }) },
{ id: "p-tests", icon: <FlaskConical className="size-3.5" />, title: "Regression tests", go: () => navigate({ to: "/tests" }) },
{ id: "p-samples", icon: <FileVideo className="size-3.5" />, title: "Samples", go: () => navigate({ to: "/samples" }) },
{ id: "p-status", icon: <Gauge className="size-3.5" />, 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: <FlaskConical className="size-3.5" />,
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: <FileVideo className="size-3.5" />,
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 (
<>
<button
className="press flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg border bg-card px-2.5 text-[13px] text-faint shadow-card transition-colors hover:border-border-strong hover:text-muted-foreground"
onClick={() => setOpen(true)}
>
<Search className="size-3.5" />
Search…
<kbd className="ml-auto rounded border bg-muted px-1 font-sans text-[10px] text-faint">
⌘K
</kbd>
</button>

<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/30 pt-[16vh] backdrop-blur-[2px]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.14 }}
onClick={() => setOpen(false)}
>
<motion.div
className="w-full max-w-lg overflow-hidden rounded-xl border bg-card shadow-pop"
initial={{ scale: 0.97, y: -8, opacity: 0 }}
animate={{ scale: 1, y: 0, opacity: 1 }}
exit={{ scale: 0.97, y: -8, opacity: 0 }}
transition={{ type: "spring", stiffness: 500, damping: 38 }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b px-3.5">
<Search className="size-4 text-faint" />
<input
ref={inputRef}
className="h-11 w-full bg-transparent text-sm outline-none placeholder:text-faint"
placeholder="Search tests by id/command, samples, pages…"
value={q}
onChange={(e) => {
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]);
}
}}
/>
</div>
<div className="max-h-80 overflow-y-auto p-1.5">
{items.map((item, i) => (
<button
key={item.id}
onMouseEnter={() => setCursor(i)}
onClick={() => pick(item)}
className={cn(
"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] transition-colors",
i === cursor ? "bg-accent text-accent-foreground" : "text-muted-foreground",
)}
>
<span className="text-faint">{item.icon}</span>
<span className="min-w-0 flex-1 truncate font-medium">{item.title}</span>
{item.subtitle && (
<span className="shrink-0 text-[11px] text-faint">{item.subtitle}</span>
)}
</button>
))}
{items.length === 0 && (
<div className="px-3 py-8 text-center text-[13px] text-faint">No matches.</div>
)}
</div>
<div className="flex gap-3 border-t bg-muted/40 px-3.5 py-2 text-[10px] text-faint">
<span>↑↓ navigate</span>
<span>↵ open</span>
<span>esc close</span>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}
219 changes: 219 additions & 0 deletions web/src/components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -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 <Outlet />;
if (!session) return <Login onDone={() => setSession(getSession())} />;

return (
<div className="relative flex h-full overflow-hidden p-2">
{/* Rail reserves collapsed width; sidebar overlays on hover so main never reflows. */}
<div style={{ width: COLLAPSED }} className="shrink-0" />

<motion.aside
onMouseEnter={() => 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",
)}
>
<div className="flex items-center gap-2.5 px-2.5 py-2.5">
<img src={asset("ccx.svg")} alt="CCExtractor" className="size-7 shrink-0" />
<span
className={cn(
"whitespace-nowrap text-[13px] font-semibold tracking-tight transition-opacity",
open ? "opacity-100" : "opacity-0",
)}
>
Sample Platform
</span>
{open && (
<button
onClick={toggle}
aria-label="Toggle theme"
className="ml-auto cursor-pointer rounded-md p-1.5 text-faint transition-colors hover:bg-muted hover:text-foreground"
>
{dark ? <Sun className="size-3.5" /> : <Moon className="size-3.5" />}
</button>
)}
</div>

<div className="mb-2 px-1.5">
{open ? (
<CommandPalette />
) : (
<div className="flex h-8 items-center rounded-lg px-2 text-faint">
<Search className="size-4 shrink-0" />
</div>
)}
</div>

<nav className="flex flex-1 flex-col gap-3 overflow-y-auto overflow-x-hidden px-1.5">
{sections.map((section) => (
<div key={section.id} className="flex flex-col gap-px">
{/* Always reserve the header row so icons don't shift on expand. */}
<div className="h-4 px-2 pb-1 text-[11px] font-medium text-faint">
<span className={cn("transition-opacity", open ? "opacity-100" : "opacity-0")}>
{section.label ?? ""}
</span>
</div>
{section.items.map(({ to, label, icon: Icon }) => {
const active = to === "/" ? pathname === "/" : pathname.startsWith(to);
return (
<Link
key={to}
to={to}
title={label}
className={cn(
"relative flex h-8 items-center gap-2.5 rounded-md px-2 text-[13px] font-medium transition-colors",
active
? "text-foreground"
: "text-muted-foreground hover:bg-muted/70 hover:text-foreground",
)}
>
{active && (
<motion.span
layoutId="nav-active"
className="absolute inset-0 rounded-md bg-muted shadow-card"
transition={{ type: "spring", stiffness: 500, damping: 40 }}
/>
)}
<Icon className={cn("relative size-4 shrink-0", active ? "text-primary" : "text-faint")} />
<span
className={cn(
"relative whitespace-nowrap transition-opacity",
open ? "opacity-100" : "opacity-0",
)}
>
{label}
</span>
</Link>
);
})}
</div>
))}
</nav>

<div className="flex items-center gap-2 px-2.5 py-2">
{/* The whole identity block is the way to the account page — the
classic site puts it behind the same name in the corner. */}
<Link
to="/account"
title="Your account"
className="flex min-w-0 flex-1 items-center gap-2 transition-opacity hover:opacity-75"
>
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-accent text-[10px] font-semibold uppercase text-accent-foreground">
{session.email.slice(0, 2)}
</div>
{open && (
<div className="min-w-0 leading-tight">
<div className="truncate text-xs font-medium">{session.email}</div>
<div className="text-[10px] capitalize text-faint">{session.role}</div>
</div>
)}
</Link>
{open && (
<button
onClick={logout}
title="Sign out"
className="cursor-pointer rounded-md p-1.5 text-faint transition-colors hover:bg-muted hover:text-foreground"
>
<LogOut className="size-3.5" />
</button>
)}
</div>
</motion.aside>

<main className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
<motion.div
key={pathname}
className="min-h-0 flex-1 overflow-y-auto"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: [0.25, 0.1, 0.25, 1] }}
>
<Outlet />
</motion.div>
</main>
</div>
);
}
Loading
Loading