Add top navigation shell and about page

This commit is contained in:
Janpeter Visser 2026-04-19 03:50:06 +02:00
parent 414491801a
commit 4966d493cc
14 changed files with 630 additions and 310 deletions

View file

@ -0,0 +1,89 @@
"use client";
import Link from "next/link";
import { CircleUserRoundIcon, LayoutDashboardIcon, LogInIcon, LogOutIcon, UserPlusIcon } from "lucide-react";
import { signOutAction } from "@/app/auth-actions";
import type { AuthState } from "@/lib/auth/session";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
type AccountMenuProps = {
authState: AuthState;
};
export function AccountMenu({ authState }: AccountMenuProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger aria-label="Account menu">
<CircleUserRoundIcon className="size-4" />
Account
</DropdownMenuTrigger>
<DropdownMenuContent>
{authState.isConfigured ? (
authState.isAuthenticated ? (
<>
<DropdownMenuLabel className="normal-case tracking-normal text-foreground">
{authState.email ?? "Ingelogde gebruiker"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
render={<Link href="/dashboard" />}
>
<LayoutDashboardIcon className="size-4" />
Dashboard
</DropdownMenuItem>
<DropdownMenuItem
render={<Link href="/check-in" />}
>
<LogInIcon className="size-4" />
Check-in
</DropdownMenuItem>
<DropdownMenuSeparator />
<form action={signOutAction}>
<DropdownMenuItem
nativeButton
render={<button type="submit" />}
>
<LogOutIcon className="size-4" />
Uitloggen
</DropdownMenuItem>
</form>
</>
) : (
<>
<DropdownMenuLabel>Niet ingelogd</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
render={<Link href="/login" />}
>
<LogInIcon className="size-4" />
Inloggen
</DropdownMenuItem>
<DropdownMenuItem
render={<Link href="/sign-up" />}
>
<UserPlusIcon className="size-4" />
Account aanmaken
</DropdownMenuItem>
</>
)
) : (
<>
<DropdownMenuLabel>Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem disabled>
<LogInIcon className="size-4" />
Auth nog niet geconfigureerd
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import { getAuthState } from "@/lib/auth/session";
import { TopNav } from "@/components/navigation/top-nav";
import { cn } from "@/lib/utils";
type AppShellProps = {
children: ReactNode;
contentClassName?: string;
};
export async function AppShell({
children,
contentClassName,
}: AppShellProps) {
const authState = await getAuthState();
return (
<main className="app-page">
<div className="mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-8">
<TopNav authState={authState} />
<div className={cn("flex-1", contentClassName)}>{children}</div>
</div>
</main>
);
}

View file

@ -0,0 +1,40 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
type PageIntroProps = {
eyebrow: string;
title: string;
description: string;
aside?: ReactNode;
className?: string;
};
export function PageIntro({
eyebrow,
title,
description,
aside,
className,
}: PageIntroProps) {
return (
<section
className={cn(
"flex flex-col gap-4 rounded-[var(--radius-4xl)] border border-border/70 bg-card/72 p-6 shadow-[var(--shadow-1)] backdrop-blur sm:flex-row sm:items-end sm:justify-between sm:p-8",
className,
)}
>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
{eyebrow}
</p>
<h1 className="mt-3 font-[family-name:var(--font-display)] text-4xl leading-tight">
{title}
</h1>
<p className="mt-4 max-w-3xl text-base leading-8 text-muted-foreground">
{description}
</p>
</div>
{aside ? <div className="sm:pl-6">{aside}</div> : null}
</section>
);
}

View file

@ -0,0 +1,62 @@
"use client";
import { MoonStarIcon, MonitorCogIcon, SunMediumIcon } from "lucide-react";
import { useTheme } from "next-themes";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const themeOptions = [
{ value: "light", label: "Light", icon: SunMediumIcon },
{ value: "dark", label: "Dark", icon: MoonStarIcon },
{ value: "system", label: "System", icon: MonitorCogIcon },
] as const;
function getThemeIcon(theme: string | undefined) {
if (theme === "light") {
return <SunMediumIcon className="size-4" />;
}
if (theme === "dark") {
return <MoonStarIcon className="size-4" />;
}
return <MonitorCogIcon className="size-4" />;
}
export function ThemeMenu() {
const { theme, setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger aria-label="Thema kiezen">
{getThemeIcon(theme)}
Theme
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Weergave</DropdownMenuLabel>
<DropdownMenuRadioGroup value={theme ?? "system"} onValueChange={setTheme}>
{themeOptions.map((option) => {
const OptionIcon = option.icon;
return (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
closeOnClick
>
<OptionIcon className="size-4" />
{option.label}
</DropdownMenuRadioItem>
);
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,90 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ActivityIcon, InfoIcon, Settings2Icon } from "lucide-react";
import type { AuthState } from "@/lib/auth/session";
import { AccountMenu } from "@/components/navigation/account-menu";
import { ThemeMenu } from "@/components/navigation/theme-menu";
import { cn } from "@/lib/utils";
const primaryNavItems = [
{
href: "/",
label: "About",
icon: InfoIcon,
},
{
href: "/planning",
label: "Planning",
icon: ActivityIcon,
},
{
href: "/settings",
label: "Instellingen",
icon: Settings2Icon,
},
] as const;
type TopNavProps = {
authState: AuthState;
};
function isActivePath(pathname: string, href: string) {
if (href === "/") {
return pathname === "/";
}
return pathname === href || pathname.startsWith(`${href}/`);
}
export function TopNav({ authState }: TopNavProps) {
const pathname = usePathname();
return (
<header className="sticky top-4 z-40">
<div className="flex flex-wrap items-center gap-4 rounded-[var(--radius-4xl)] border border-border/70 bg-card/86 px-5 py-4 shadow-[var(--shadow-2)] backdrop-blur">
<Link href="/" className="shrink-0">
<span className="block text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
Inspannings Monitor
</span>
<span className="mt-1 block text-base font-semibold tracking-[-0.02em] text-foreground">
Wellness-first dagflow
</span>
</Link>
<nav
aria-label="Hoofdnavigatie"
className="flex flex-1 flex-wrap items-center gap-2 md:ml-6"
>
{primaryNavItems.map((item) => {
const isActive = pathname ? isActivePath(pathname, item.href) : false;
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
className={cn(
"inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground shadow-[var(--shadow-1)]"
: "text-muted-foreground hover:bg-secondary hover:text-foreground",
)}
>
<Icon className="size-4" />
{item.label}
</Link>
);
})}
</nav>
<div className="ml-auto flex flex-wrap items-center gap-2">
<ThemeMenu />
<AccountMenu authState={authState} />
</div>
</div>
</header>
);
}

View file

@ -0,0 +1,155 @@
"use client";
import * as React from "react";
import { Menu } from "@base-ui/react/menu";
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = Menu.Root;
function DropdownMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof Menu.Trigger>) {
return (
<Menu.Trigger
data-slot="dropdown-menu-trigger"
className={cn(
"inline-flex items-center gap-2 rounded-full border border-border/75 bg-card/84 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-all duration-150 ease-[cubic-bezier(.2,.7,.2,1)] hover:border-border hover:bg-card focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="size-4 text-muted-foreground" />
</Menu.Trigger>
);
}
type DropdownMenuContentProps = React.ComponentProps<typeof Menu.Popup> &
Pick<
React.ComponentProps<typeof Menu.Positioner>,
"align" | "alignOffset" | "side" | "sideOffset"
>;
function DropdownMenuContent({
className,
children,
side = "bottom",
sideOffset = 10,
align = "end",
alignOffset = 0,
...props
}: DropdownMenuContentProps) {
return (
<Menu.Portal>
<Menu.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
className="z-50"
>
<Menu.Popup
data-slot="dropdown-menu-content"
className={cn(
"z-50 min-w-60 overflow-hidden rounded-[var(--radius-2xl)] border border-border/80 bg-popover/96 p-1.5 text-popover-foreground shadow-[var(--shadow-3)] backdrop-blur duration-150 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
{children}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
);
}
function DropdownMenuLabel({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="dropdown-menu-label"
className={cn(
"px-3 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground",
className,
)}
{...props}
/>
);
}
function DropdownMenuItem({
className,
inset = false,
...props
}: React.ComponentProps<typeof Menu.Item> & { inset?: boolean }) {
return (
<Menu.Item
data-slot="dropdown-menu-item"
className={cn(
"flex cursor-default items-center gap-2 rounded-[var(--radius)] px-3 py-2.5 text-sm text-foreground outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
inset && "pl-9",
className,
)}
{...props}
/>
);
}
function DropdownMenuRadioGroup(
props: React.ComponentProps<typeof Menu.RadioGroup>,
) {
return <Menu.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof Menu.RadioItem>) {
return (
<Menu.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"flex cursor-default items-center gap-2 rounded-[var(--radius)] px-3 py-2.5 text-sm text-foreground outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[checked]:text-primary",
className,
)}
{...props}
>
<span className="flex size-4 items-center justify-center">
<Menu.RadioItemIndicator>
<CheckIcon className="size-4" />
</Menu.RadioItemIndicator>
</span>
<span>{children}</span>
</Menu.RadioItem>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof Menu.Separator>) {
return (
<Menu.Separator
data-slot="dropdown-menu-separator"
className={cn("my-1 h-px bg-border/80", className)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
};