Executable → Regular
+109
-26
@@ -1,4 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Menu } from "lucide-react";
|
||||
import { Button } from "../components/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "../components/dialog";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export interface NavItem {
|
||||
@@ -9,50 +14,128 @@ export interface NavItem {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/** Dashboard chrome: fixed sidebar + sticky topbar + scrolling content. */
|
||||
function ShellNav({
|
||||
items,
|
||||
label,
|
||||
onNavigate,
|
||||
}: {
|
||||
items: NavItem[];
|
||||
label: string;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<nav aria-label={label} className="flex-1 space-y-0.5 px-3 py-3">
|
||||
{items.map((item) => {
|
||||
const classes = cn(
|
||||
"flex w-full items-center gap-3 rounded px-3 py-2 text-sm font-medium transition-colors duration-fast",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
item.active ? "bg-brand/15 text-brand" : "text-muted hover:bg-surface-2/60 hover:text-text"
|
||||
);
|
||||
const content = <>{item.icon}<span className="truncate">{item.label}</span></>;
|
||||
if (item.href) {
|
||||
return (
|
||||
<a
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
aria-current={item.active ? "page" : undefined}
|
||||
onClick={() => {
|
||||
item.onClick?.();
|
||||
onNavigate?.();
|
||||
}}
|
||||
className={classes}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
aria-current={item.active ? "page" : undefined}
|
||||
onClick={() => {
|
||||
item.onClick?.();
|
||||
onNavigate?.();
|
||||
}}
|
||||
className={classes}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/** Responsive dashboard chrome with desktop navigation and a mobile drawer. */
|
||||
export function AppShell({
|
||||
brand,
|
||||
nav,
|
||||
topbar,
|
||||
children,
|
||||
footer,
|
||||
navLabel = "Primary navigation",
|
||||
className,
|
||||
mainClassName,
|
||||
}: {
|
||||
brand: React.ReactNode;
|
||||
nav: NavItem[];
|
||||
topbar?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
navLabel?: string;
|
||||
className?: string;
|
||||
mainClassName?: string;
|
||||
}) {
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
|
||||
const sidebar = (mobile = false) => (
|
||||
<>
|
||||
<div className={cn("flex h-14 items-center px-5 font-display text-lg font-bold", mobile && "pr-12")}>
|
||||
{brand}
|
||||
</div>
|
||||
<ShellNav items={nav} label={navLabel} onNavigate={mobile ? () => setMobileOpen(false) : undefined} />
|
||||
{footer && <div className="border-t border-border p-3 text-sm text-muted">{footer}</div>}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-bg text-text">
|
||||
<div className={cn("flex min-h-dvh bg-bg text-text", className)}>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="fixed left-3 top-3 z-[70] -translate-y-20 rounded bg-brand px-3 py-2 text-sm font-semibold text-brand-foreground shadow-lg focus:translate-y-0"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
|
||||
<aside className="hidden w-60 shrink-0 flex-col border-r border-border bg-surface md:flex">
|
||||
<div className="flex h-14 items-center px-5 font-display text-lg font-bold">{brand}</div>
|
||||
<nav className="flex-1 space-y-0.5 px-3 py-3">
|
||||
{nav.map((item) => {
|
||||
const Comp = item.href ? "a" : "button";
|
||||
return (
|
||||
<Comp
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
onClick={item.onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded px-3 py-2 text-sm font-medium transition-colors duration-fast [&_svg]:size-4",
|
||||
item.active ? "bg-brand/15 text-brand" : "text-muted hover:bg-surface-2/60 hover:text-text"
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Comp>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{footer && <div className="border-t border-border p-3 text-sm text-muted">{footer}</div>}
|
||||
{sidebar()}
|
||||
</aside>
|
||||
|
||||
<Dialog open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<DialogContent className="inset-y-0 left-0 top-0 flex h-dvh w-[min(20rem,88vw)] max-w-none translate-x-0 translate-y-0 flex-col rounded-none border-y-0 border-l-0 p-0 md:hidden">
|
||||
<DialogTitle className="sr-only">{navLabel}</DialogTitle>
|
||||
{sidebar(true)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center justify-between gap-4 border-b border-border bg-bg/80 px-5 backdrop-blur">
|
||||
{topbar}
|
||||
<header className="sticky top-0 z-30 flex min-h-14 items-center gap-3 border-b border-border bg-bg/85 px-4 py-2 backdrop-blur md:px-5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 md:hidden"
|
||||
aria-label="Open navigation"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<Menu />
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">{topbar ?? <div className="font-display font-semibold md:hidden">{brand}</div>}</div>
|
||||
</header>
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
<main id="main-content" tabIndex={-1} className={cn("flex-1 p-4 outline-none sm:p-6", mainClassName)}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,9 +45,11 @@ export function CommandPalette({
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
label="Command palette"
|
||||
overlayClassName="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
|
||||
contentClassName="fixed left-1/2 top-[18vh] z-50 w-[calc(100%-2rem)] max-w-xl -translate-x-1/2"
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/4 z-50 w-full max-w-xl -translate-x-1/2 overflow-hidden",
|
||||
"rounded-lg border border-border bg-surface shadow-lg data-[state=open]:animate-scale-in"
|
||||
"overflow-hidden rounded-lg border border-border bg-surface shadow-lg",
|
||||
"data-[state=open]:animate-scale-in"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4">
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ export function Hero({
|
||||
)}
|
||||
{(primaryCta || secondaryCta) && (
|
||||
<FadeIn delay={0.15}>
|
||||
<div className="mt-10 flex items-center justify-center gap-3">
|
||||
<div className="mt-10 flex flex-col items-stretch justify-center gap-3 sm:flex-row sm:items-center">
|
||||
{primaryCta && (
|
||||
<Button size="lg" asChild={!!primaryCta.href} onClick={primaryCta.onClick}>
|
||||
{primaryCta.href ? <a href={primaryCta.href}>{primaryCta.label}</a> : primaryCta.label}
|
||||
|
||||
Reference in New Issue
Block a user