Skip to content

Commit f6cb412

Browse files
pulk17canihavesomecoffee
authored andcommitted
Web-Shell
Sidebar, command palette and the router that ties the pages together. The console runs from here.
1 parent 539bac6 commit f6cb412

4 files changed

Lines changed: 563 additions & 0 deletions

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { useNavigate } from "@tanstack/react-router";
2+
import { Activity, FileVideo, FlaskConical, Gauge, Home, Search } from "lucide-react";
3+
import { AnimatePresence, motion } from "motion/react";
4+
import { useEffect, useMemo, useRef, useState } from "react";
5+
6+
import { useRegressionTests, useSamples } from "@/lib/api";
7+
import { cn } from "@/lib/utils";
8+
9+
interface Item {
10+
id: string;
11+
icon: React.ReactNode;
12+
title: string;
13+
subtitle?: string;
14+
go: () => void;
15+
}
16+
17+
/** Real ⌘K palette: pages, regression tests, samples — keyboard-first. */
18+
export function CommandPalette() {
19+
const [open, setOpen] = useState(false);
20+
const [q, setQ] = useState("");
21+
const [cursor, setCursor] = useState(0);
22+
const navigate = useNavigate();
23+
const inputRef = useRef<HTMLInputElement>(null);
24+
const { data: tests = [] } = useRegressionTests();
25+
const { data: samples = [] } = useSamples();
26+
27+
useEffect(() => {
28+
const onKey = (e: KeyboardEvent) => {
29+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
30+
e.preventDefault();
31+
setOpen((o) => !o);
32+
setQ("");
33+
setCursor(0);
34+
}
35+
if (e.key === "Escape") setOpen(false);
36+
};
37+
window.addEventListener("keydown", onKey);
38+
return () => window.removeEventListener("keydown", onKey);
39+
}, []);
40+
41+
useEffect(() => {
42+
if (open) setTimeout(() => inputRef.current?.focus(), 30);
43+
}, [open]);
44+
45+
const items = useMemo<Item[]>(() => {
46+
const needle = q.trim().toLowerCase();
47+
const pages: Item[] = [
48+
{ id: "p-home", icon: <Home className="size-3.5" />, title: "Home — triage inbox", go: () => navigate({ to: "/" }) },
49+
{ id: "p-runs", icon: <Activity className="size-3.5" />, title: "Test results", go: () => navigate({ to: "/runs" }) },
50+
{ id: "p-tests", icon: <FlaskConical className="size-3.5" />, title: "Regression tests", go: () => navigate({ to: "/tests" }) },
51+
{ id: "p-samples", icon: <FileVideo className="size-3.5" />, title: "Samples", go: () => navigate({ to: "/samples" }) },
52+
{ id: "p-status", icon: <Gauge className="size-3.5" />, title: "Platform status", go: () => navigate({ to: "/status" }) },
53+
];
54+
if (!needle) return pages;
55+
56+
const testHits: Item[] = tests
57+
.filter((t) => `#${t.id} ${t.command} ${t.sample_name}`.toLowerCase().includes(needle))
58+
.slice(0, 6)
59+
.map((t) => ({
60+
id: `t-${t.id}`,
61+
icon: <FlaskConical className="size-3.5" />,
62+
title: `#${t.id} ${t.command}`,
63+
subtitle: t.sample_name,
64+
go: () => navigate({ to: "/tests", search: { t: t.id } }),
65+
}));
66+
const sampleHits: Item[] = samples
67+
.filter((s) => `${s.original_name} ${s.sha}`.toLowerCase().includes(needle))
68+
.slice(0, 4)
69+
.map((s) => ({
70+
id: `s-${s.id}`,
71+
icon: <FileVideo className="size-3.5" />,
72+
title: s.original_name,
73+
subtitle: s.extension,
74+
go: () => navigate({ to: "/samples" }),
75+
}));
76+
return [
77+
...pages.filter((p) => p.title.toLowerCase().includes(needle)),
78+
...testHits,
79+
...sampleHits,
80+
];
81+
}, [q, tests, samples, navigate]);
82+
83+
const pick = (item: Item) => {
84+
item.go();
85+
setOpen(false);
86+
};
87+
88+
return (
89+
<>
90+
<button
91+
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"
92+
onClick={() => setOpen(true)}
93+
>
94+
<Search className="size-3.5" />
95+
Search…
96+
<kbd className="ml-auto rounded border bg-muted px-1 font-sans text-[10px] text-faint">
97+
⌘K
98+
</kbd>
99+
</button>
100+
101+
<AnimatePresence>
102+
{open && (
103+
<motion.div
104+
className="fixed inset-0 z-50 flex items-start justify-center bg-black/30 pt-[16vh] backdrop-blur-[2px]"
105+
initial={{ opacity: 0 }}
106+
animate={{ opacity: 1 }}
107+
exit={{ opacity: 0 }}
108+
transition={{ duration: 0.14 }}
109+
onClick={() => setOpen(false)}
110+
>
111+
<motion.div
112+
className="w-full max-w-lg overflow-hidden rounded-xl border bg-card shadow-pop"
113+
initial={{ scale: 0.97, y: -8, opacity: 0 }}
114+
animate={{ scale: 1, y: 0, opacity: 1 }}
115+
exit={{ scale: 0.97, y: -8, opacity: 0 }}
116+
transition={{ type: "spring", stiffness: 500, damping: 38 }}
117+
onClick={(e) => e.stopPropagation()}
118+
>
119+
<div className="flex items-center gap-2 border-b px-3.5">
120+
<Search className="size-4 text-faint" />
121+
<input
122+
ref={inputRef}
123+
className="h-11 w-full bg-transparent text-sm outline-none placeholder:text-faint"
124+
placeholder="Search tests by id/command, samples, pages…"
125+
value={q}
126+
onChange={(e) => {
127+
setQ(e.target.value);
128+
setCursor(0);
129+
}}
130+
onKeyDown={(e) => {
131+
if (e.key === "ArrowDown") {
132+
e.preventDefault();
133+
setCursor((c) => Math.min(c + 1, items.length - 1));
134+
} else if (e.key === "ArrowUp") {
135+
e.preventDefault();
136+
setCursor((c) => Math.max(c - 1, 0));
137+
} else if (e.key === "Enter" && items[cursor]) {
138+
pick(items[cursor]);
139+
}
140+
}}
141+
/>
142+
</div>
143+
<div className="max-h-80 overflow-y-auto p-1.5">
144+
{items.map((item, i) => (
145+
<button
146+
key={item.id}
147+
onMouseEnter={() => setCursor(i)}
148+
onClick={() => pick(item)}
149+
className={cn(
150+
"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] transition-colors",
151+
i === cursor ? "bg-accent text-accent-foreground" : "text-muted-foreground",
152+
)}
153+
>
154+
<span className="text-faint">{item.icon}</span>
155+
<span className="min-w-0 flex-1 truncate font-medium">{item.title}</span>
156+
{item.subtitle && (
157+
<span className="shrink-0 text-[11px] text-faint">{item.subtitle}</span>
158+
)}
159+
</button>
160+
))}
161+
{items.length === 0 && (
162+
<div className="px-3 py-8 text-center text-[13px] text-faint">No matches.</div>
163+
)}
164+
</div>
165+
<div className="flex gap-3 border-t bg-muted/40 px-3.5 py-2 text-[10px] text-faint">
166+
<span>↑↓ navigate</span>
167+
<span>↵ open</span>
168+
<span>esc close</span>
169+
</div>
170+
</motion.div>
171+
</motion.div>
172+
)}
173+
</AnimatePresence>
174+
</>
175+
);
176+
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { Link, Outlet, useRouterState } from "@tanstack/react-router";
2+
import {
3+
Activity,
4+
FileVideo,
5+
FlaskConical,
6+
Gauge,
7+
Inbox,
8+
LogOut,
9+
Moon,
10+
Search,
11+
Settings,
12+
Sun,
13+
UploadCloud,
14+
} from "lucide-react";
15+
import { motion } from "motion/react";
16+
import { useEffect, useState } from "react";
17+
18+
import { CommandPalette } from "@/components/CommandPalette";
19+
import { Login } from "@/components/Login";
20+
import { SESSION_CHANGED, getSession, logout } from "@/lib/auth";
21+
import { asset, cn } from "@/lib/utils";
22+
23+
/* Classic-site menu names so migrating devs feel at home. */
24+
const sections = [
25+
{
26+
id: "main",
27+
label: null,
28+
items: [
29+
{ to: "/", label: "Home", icon: Inbox },
30+
{ to: "/runs", label: "Test results", icon: Activity },
31+
],
32+
},
33+
{
34+
id: "suite",
35+
label: "Suite",
36+
items: [
37+
{ to: "/tests", label: "Regression tests", icon: FlaskConical },
38+
{ to: "/samples", label: "Samples", icon: FileVideo },
39+
{ to: "/upload", label: "Sample upload", icon: UploadCloud },
40+
],
41+
},
42+
{
43+
id: "platform",
44+
label: "Platform",
45+
items: [
46+
{ to: "/status", label: "Platform status", icon: Gauge },
47+
{ to: "/admin", label: "Administration", icon: Settings },
48+
],
49+
},
50+
] as const;
51+
52+
function useTheme() {
53+
const [dark, setDark] = useState(() => localStorage.getItem("sp-theme") === "dark");
54+
useEffect(() => {
55+
document.documentElement.classList.toggle("dark", dark);
56+
localStorage.setItem("sp-theme", dark ? "dark" : "light");
57+
}, [dark]);
58+
return { dark, toggle: () => setDark((d) => !d) };
59+
}
60+
61+
const COLLAPSED = 52;
62+
const EXPANDED = 224;
63+
64+
export function AppShell() {
65+
const { dark, toggle } = useTheme();
66+
const pathname = useRouterState({ select: (s) => s.location.pathname });
67+
const [session, setSession] = useState(getSession);
68+
const [open, setOpen] = useState(false);
69+
70+
// The account page can change the email shown here, so pick the stored
71+
// session back up instead of leaving the corner reading the old one.
72+
useEffect(() => {
73+
const sync = () => setSession(getSession());
74+
window.addEventListener(SESSION_CHANGED, sync);
75+
return () => window.removeEventListener(SESSION_CHANGED, sync);
76+
}, []);
77+
78+
// The reset screen arrives from an email with nobody signed in, so it
79+
// renders on its own rather than behind the sign-in gate.
80+
if (pathname === "/reset") return <Outlet />;
81+
if (!session) return <Login onDone={() => setSession(getSession())} />;
82+
83+
return (
84+
<div className="relative flex h-full overflow-hidden p-2">
85+
{/* Rail reserves collapsed width; sidebar overlays on hover so main never reflows. */}
86+
<div style={{ width: COLLAPSED }} className="shrink-0" />
87+
88+
<motion.aside
89+
onMouseEnter={() => setOpen(true)}
90+
onMouseLeave={() => setOpen(false)}
91+
initial={false}
92+
animate={{ width: open ? EXPANDED : COLLAPSED }}
93+
transition={{ type: "spring", stiffness: 420, damping: 38 }}
94+
className={cn(
95+
"absolute inset-y-2 left-2 z-30 flex flex-col overflow-hidden rounded-xl",
96+
open && "border bg-card shadow-pop",
97+
)}
98+
>
99+
<div className="flex items-center gap-2.5 px-2.5 py-2.5">
100+
<img src={asset("ccx.svg")} alt="CCExtractor" className="size-7 shrink-0" />
101+
<span
102+
className={cn(
103+
"whitespace-nowrap text-[13px] font-semibold tracking-tight transition-opacity",
104+
open ? "opacity-100" : "opacity-0",
105+
)}
106+
>
107+
Sample Platform
108+
</span>
109+
{open && (
110+
<button
111+
onClick={toggle}
112+
aria-label="Toggle theme"
113+
className="ml-auto cursor-pointer rounded-md p-1.5 text-faint transition-colors hover:bg-muted hover:text-foreground"
114+
>
115+
{dark ? <Sun className="size-3.5" /> : <Moon className="size-3.5" />}
116+
</button>
117+
)}
118+
</div>
119+
120+
<div className="mb-2 px-1.5">
121+
{open ? (
122+
<CommandPalette />
123+
) : (
124+
<div className="flex h-8 items-center rounded-lg px-2 text-faint">
125+
<Search className="size-4 shrink-0" />
126+
</div>
127+
)}
128+
</div>
129+
130+
<nav className="flex flex-1 flex-col gap-3 overflow-y-auto overflow-x-hidden px-1.5">
131+
{sections.map((section) => (
132+
<div key={section.id} className="flex flex-col gap-px">
133+
{/* Always reserve the header row so icons don't shift on expand. */}
134+
<div className="h-4 px-2 pb-1 text-[11px] font-medium text-faint">
135+
<span className={cn("transition-opacity", open ? "opacity-100" : "opacity-0")}>
136+
{section.label ?? ""}
137+
</span>
138+
</div>
139+
{section.items.map(({ to, label, icon: Icon }) => {
140+
const active = to === "/" ? pathname === "/" : pathname.startsWith(to);
141+
return (
142+
<Link
143+
key={to}
144+
to={to}
145+
title={label}
146+
className={cn(
147+
"relative flex h-8 items-center gap-2.5 rounded-md px-2 text-[13px] font-medium transition-colors",
148+
active
149+
? "text-foreground"
150+
: "text-muted-foreground hover:bg-muted/70 hover:text-foreground",
151+
)}
152+
>
153+
{active && (
154+
<motion.span
155+
layoutId="nav-active"
156+
className="absolute inset-0 rounded-md bg-muted shadow-card"
157+
transition={{ type: "spring", stiffness: 500, damping: 40 }}
158+
/>
159+
)}
160+
<Icon className={cn("relative size-4 shrink-0", active ? "text-primary" : "text-faint")} />
161+
<span
162+
className={cn(
163+
"relative whitespace-nowrap transition-opacity",
164+
open ? "opacity-100" : "opacity-0",
165+
)}
166+
>
167+
{label}
168+
</span>
169+
</Link>
170+
);
171+
})}
172+
</div>
173+
))}
174+
</nav>
175+
176+
<div className="flex items-center gap-2 px-2.5 py-2">
177+
{/* The whole identity block is the way to the account page — the
178+
classic site puts it behind the same name in the corner. */}
179+
<Link
180+
to="/account"
181+
title="Your account"
182+
className="flex min-w-0 flex-1 items-center gap-2 transition-opacity hover:opacity-75"
183+
>
184+
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-accent text-[10px] font-semibold uppercase text-accent-foreground">
185+
{session.email.slice(0, 2)}
186+
</div>
187+
{open && (
188+
<div className="min-w-0 leading-tight">
189+
<div className="truncate text-xs font-medium">{session.email}</div>
190+
<div className="text-[10px] capitalize text-faint">{session.role}</div>
191+
</div>
192+
)}
193+
</Link>
194+
{open && (
195+
<button
196+
onClick={logout}
197+
title="Sign out"
198+
className="cursor-pointer rounded-md p-1.5 text-faint transition-colors hover:bg-muted hover:text-foreground"
199+
>
200+
<LogOut className="size-3.5" />
201+
</button>
202+
)}
203+
</div>
204+
</motion.aside>
205+
206+
<main className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
207+
<motion.div
208+
key={pathname}
209+
className="min-h-0 flex-1 overflow-y-auto"
210+
initial={{ opacity: 0, y: 6 }}
211+
animate={{ opacity: 1, y: 0 }}
212+
transition={{ duration: 0.22, ease: [0.25, 0.1, 0.25, 1] }}
213+
>
214+
<Outlet />
215+
</motion.div>
216+
</main>
217+
</div>
218+
);
219+
}

0 commit comments

Comments
 (0)