Add top navigation shell and about page
This commit is contained in:
parent
414491801a
commit
4966d493cc
14 changed files with 630 additions and 310 deletions
89
components/navigation/account-menu.tsx
Normal file
89
components/navigation/account-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
components/navigation/app-shell.tsx
Normal file
25
components/navigation/app-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
components/navigation/page-intro.tsx
Normal file
40
components/navigation/page-intro.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
components/navigation/theme-menu.tsx
Normal file
62
components/navigation/theme-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
components/navigation/top-nav.tsx
Normal file
90
components/navigation/top-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
components/ui/dropdown-menu.tsx
Normal file
155
components/ui/dropdown-menu.tsx
Normal 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,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue