presentercoach / apps /web /src /components /app-shell.tsx
nathycodes's picture
Deploy Tech Presenter Coach
ec17af3
Raw History Blame Contribute Delete
8.23 kB
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/store/useAuthStore";
type NavItem = {
href: string;
label: string;
shortLabel: string;
icon: (active: boolean) => ReactNode;
};
const NAV_ITEMS: NavItem[] = [
{
href: "/",
label: "Command Center",
shortLabel: "Home",
icon: (active) => (
<svg className={cn("h-5 w-5", active ? "text-ember" : "text-ink/65")} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 10.5L12 3l9 7.5V21a1 1 0 0 1-1 1h-5v-6h-6v6H4a1 1 0 0 1-1-1v-10.5z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
},
{
href: "/curriculum",
label: "30-Day Plan",
shortLabel: "30-Day",
icon: (active) => (
<svg className={cn("h-5 w-5", active ? "text-ember" : "text-ink/65")} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="17" rx="3" />
<path d="M8 2v4M16 2v4M3 9h18M8 14l2.5 2.5L16 11" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
},
{
href: "/record",
label: "Camera Studio",
shortLabel: "Studio",
icon: (active) => (
<svg className={cn("h-5 w-5", active ? "text-ember" : "text-ink/65")} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="6" width="14" height="12" rx="2.5" />
<path d="M16 10l6-3.5v11L16 14v-4z" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="9" cy="12" r="2.2" fill="currentColor" />
</svg>
),
},
{
href: "/tech-news",
label: "Tech News Lab",
shortLabel: "News",
icon: (active) => (
<svg className={cn("h-5 w-5", active ? "text-ember" : "text-ink/65")} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 5h13a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5z" />
<path d="M19 8h1a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-1M8 9h7M8 13h7M8 17h4" strokeLinecap="round" />
</svg>
),
},
{
href: "/exercises",
label: "Daily Drills",
shortLabel: "Drills",
icon: (active) => (
<svg className={cn("h-5 w-5", active ? "text-ember" : "text-ink/65")} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M13 2L3 14h8l-1 8 10-12h-8l1-8z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
},
{
href: "/progress",
label: "Progress & Compare",
shortLabel: "Progress",
icon: (active) => (
<svg className={cn("h-5 w-5", active ? "text-ember" : "text-ink/65")} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 19h16M7 15V9M12 15V5M17 15v-3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
},
];
type AppShellProps = {
children: ReactNode;
title?: string;
subtitle?: string;
eyebrow?: string;
actions?: ReactNode;
maxWidthClass?: string;
};
export function AppShell({
children,
title,
subtitle,
eyebrow,
actions,
maxWidthClass = "max-w-7xl",
}: AppShellProps) {
const pathname = usePathname();
const router = useRouter();
const userEmail = useAuthStore((state) => state.userEmail);
const logout = useAuthStore((state) => state.logout);
const isActive = (href: string) => {
if (href === "/") return pathname === "/";
return pathname === href || pathname?.startsWith(`${href}/`);
};
const handleLogout = async () => {
await logout();
router.replace("/login");
};
return (
<div className="min-h-screen bg-hero-gradient pb-24 lg:pb-12">
<header className="sticky top-0 z-30 border-b border-ink/10 bg-cream/85 backdrop-blur-md">
<div className={cn("mx-auto flex items-center justify-between gap-4 px-4 py-3 sm:px-6", maxWidthClass)}>
<Link href="/" className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-ink text-white shadow-sm">
<svg className="h-5 w-5 text-ember" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<rect x="2" y="6" width="13" height="12" rx="2.5" />
<path d="M15 10.5l6-3.5v10l-6-3.5v-3z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<div className="leading-tight">
<p className="font-display text-base font-semibold tracking-tight text-ink">Speech-Machine</p>
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-ink/55">
Tech Presenter Coach
</p>
</div>
</Link>
<nav aria-label="Main navigation" className="hidden items-center gap-1 lg:flex">
{NAV_ITEMS.map((item) => {
const active = isActive(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-2 rounded-full px-3.5 py-2 text-sm font-medium transition",
active
? "bg-ink text-white shadow-sm"
: "text-ink/75 hover:bg-ink/5 hover:text-ink"
)}
>
{item.label}
</Link>
);
})}
</nav>
<div className="flex items-center gap-2">
{pathname !== "/record" && (
<Link href="/record" className="hidden sm:inline-flex">
<Button size="sm" className="bg-ember text-white hover:bg-ember/90">
Open Studio
</Button>
</Link>
)}
{userEmail && (
<span className="hidden max-w-[160px] truncate rounded-full bg-white/75 px-3 py-1.5 text-xs font-medium text-ink/70 xl:inline-block">
{userEmail}
</span>
)}
<Button variant="ghost" size="sm" onClick={handleLogout} title="Sign out">
Sign out
</Button>
</div>
</div>
</header>
<main className={cn("mx-auto px-4 pt-6 sm:px-6 lg:pt-8", maxWidthClass)}>
{(title || actions) && (
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1">
{eyebrow && (
<p className="text-xs font-semibold uppercase tracking-[0.26em] text-ember">
{eyebrow}
</p>
)}
{title && (
<h1 className="font-display text-2xl font-semibold tracking-tight text-ink sm:text-3xl lg:text-4xl">
{title}
</h1>
)}
{subtitle && <p className="max-w-3xl text-sm text-ink/70 sm:text-base">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2.5">{actions}</div>}
</div>
)}
{children}
</main>
<nav
aria-label="Mobile navigation"
className="fixed inset-x-0 bottom-0 z-40 border-t border-ink/10 bg-cream/95 px-2 pb-[calc(env(safe-area-inset-bottom)+0.35rem)] pt-1.5 backdrop-blur-md lg:hidden"
>
<div className="mx-auto grid max-w-lg grid-cols-6 gap-1">
{NAV_ITEMS.map((item) => {
const active = isActive(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex flex-col items-center justify-center gap-0.5 rounded-2xl py-1.5 text-[11px] font-medium transition",
active ? "bg-ink/8 font-semibold text-ink" : "text-ink/65 hover:text-ink"
)}
>
{item.icon(active)}
<span className="truncate">{item.shortLabel}</span>
</Link>
);
})}
</div>
</nav>
</div>
);
}