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
12
README.md
12
README.md
|
|
@ -99,6 +99,18 @@ De actuele visuele richting is `Dusk`: warme paper-achtergronden, gedempte indig
|
|||
als primaire kleur, dark mode als standaard en semantische `success`/`warning`
|
||||
tokens voor rustige, niet-medische feedback.
|
||||
|
||||
## Navigatie
|
||||
|
||||
De app gebruikt nu een gedeelde topnavigatie:
|
||||
|
||||
- links: `About`, `Planning`, `Instellingen`
|
||||
- rechts: `Account` en `Theme`
|
||||
|
||||
`/` is de publieke `About`-pagina met informatie over de maker en de scope van
|
||||
de app. In het `Account`-menu komen ingelogde gebruikers bij `Dashboard`,
|
||||
`Check-in` en `Uitloggen`; uitgelogde gebruikers zien daar `Inloggen` en
|
||||
`Account aanmaken`.
|
||||
|
||||
## Interne wizard-test
|
||||
|
||||
Er is een interne testwizard beschikbaar op `/wizard-test` om een toekomstige
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { signOutAction } from "@/app/auth-actions";
|
||||
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
|
||||
import { AppShell } from "@/components/navigation/app-shell";
|
||||
import { PageIntro } from "@/components/navigation/page-intro";
|
||||
import { CheckInForm } from "@/components/check-in/check-in-form";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -18,7 +18,6 @@ import { getTodayCheckInForCurrentUser } from "@/lib/check-in/service";
|
|||
import { getCheckInStatusToast } from "@/lib/feedback/status-messages";
|
||||
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
|
||||
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -55,45 +54,23 @@ export default async function CheckInPage({ searchParams }: CheckInPageProps) {
|
|||
);
|
||||
|
||||
return (
|
||||
<main className="app-page">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-8">
|
||||
<AppShell contentClassName="space-y-8">
|
||||
<div className="space-y-8">
|
||||
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
|
||||
|
||||
<header className="app-page-header">
|
||||
<div>
|
||||
<div className="app-page-breadcrumb">
|
||||
<Link href="/dashboard" className="app-page-link">
|
||||
Dashboard
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span>Ochtendcheck-in</span>
|
||||
</div>
|
||||
<h1 className="mt-3 font-[family-name:var(--font-display)] text-4xl leading-tight">
|
||||
Ochtendcheck-in van vandaag
|
||||
</h1>
|
||||
<p className="app-page-copy">
|
||||
Houd je start rustig en klein. Je legt alleen een energiescore en een
|
||||
globale slaapindruk vast voor vandaag.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<PageIntro
|
||||
eyebrow="Check-in"
|
||||
title="Ochtendcheck-in van vandaag"
|
||||
description="Houd je start rustig en klein. Je legt alleen een energiescore en een globale slaapindruk vast voor vandaag."
|
||||
aside={
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "lg" }),
|
||||
"h-11 rounded-full px-5",
|
||||
)}
|
||||
className="inline-flex items-center rounded-full border border-border/80 bg-card/84 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-colors hover:bg-secondary"
|
||||
>
|
||||
Terug naar dashboard
|
||||
</Link>
|
||||
<form action={signOutAction}>
|
||||
<Button type="submit" size="lg" className="h-11 rounded-full px-5">
|
||||
Uitloggen
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<CheckInForm todayCheckIn={checkInStatus?.todayCheckIn ?? null} />
|
||||
|
|
@ -138,6 +115,6 @@ export default async function CheckInPage({ searchParams }: CheckInPageProps) {
|
|||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { signOutAction } from "@/app/auth-actions";
|
||||
import { CheckInCard } from "@/components/check-in/check-in-card";
|
||||
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
|
||||
import { AppShell } from "@/components/navigation/app-shell";
|
||||
import { PageIntro } from "@/components/navigation/page-intro";
|
||||
import { EnergyMeterCard } from "@/components/planning/energy-meter-card";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -21,7 +21,6 @@ import { getTodayActivitiesForCurrentUser } from "@/lib/planning/service";
|
|||
import { calculatePlanningMeterSnapshot } from "@/lib/planning/meter";
|
||||
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
|
||||
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -77,62 +76,25 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
|||
);
|
||||
|
||||
return (
|
||||
<main className="app-page">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-8">
|
||||
<AppShell contentClassName="space-y-8">
|
||||
<div className="space-y-8">
|
||||
<StatusToastBridge toast={statusToast} />
|
||||
|
||||
<header className="app-page-header">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||
Protected route
|
||||
</p>
|
||||
<h1 className="mt-3 font-[family-name:var(--font-display)] text-4xl leading-tight">
|
||||
Dashboard placeholder voor release 1
|
||||
</h1>
|
||||
<p className="app-page-copy">
|
||||
Je sessie is server-side gevalideerd en het minimale profielbundle is
|
||||
nu beschikbaar. Daarmee staat de fundering voor onboarding, settings
|
||||
en de eerste energieflows klaar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form action={signOutAction}>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<PageIntro
|
||||
eyebrow="Dashboard"
|
||||
title="Je huidige dagstatus"
|
||||
description="Hier zie je in één overzicht je profielbasis, ochtendcheck-in, planningstatus en huidige energiemeter voor vandaag."
|
||||
aside={
|
||||
isTestWizardEnabled() ? (
|
||||
<Link
|
||||
href="/settings"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "lg" }),
|
||||
"h-11 rounded-full px-5",
|
||||
)}
|
||||
href="/wizard-test"
|
||||
className="inline-flex items-center rounded-full border border-border/80 bg-card/84 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-colors hover:bg-secondary"
|
||||
>
|
||||
Instellingen
|
||||
Test wizard
|
||||
</Link>
|
||||
<Link
|
||||
href="/planning"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "lg" }),
|
||||
"h-11 rounded-full px-5",
|
||||
)}
|
||||
>
|
||||
Dagplanning
|
||||
</Link>
|
||||
{isTestWizardEnabled() ? (
|
||||
<Link
|
||||
href="/wizard-test"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "lg" }),
|
||||
"h-11 rounded-full px-5",
|
||||
)}
|
||||
>
|
||||
Test wizard
|
||||
</Link>
|
||||
) : null}
|
||||
<Button type="submit" size="lg" className="h-11 rounded-full px-5">
|
||||
Uitloggen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</header>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="grid gap-5 md:grid-cols-3">
|
||||
<Card className="py-0">
|
||||
|
|
@ -214,13 +176,7 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
|||
Plan kleine, concrete activiteiten voor vandaag en bouw daarna verder op budgetfeedback en evaluatie.
|
||||
</CardDescription>
|
||||
<div className="mt-4">
|
||||
<Link
|
||||
href="/planning"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "lg" }),
|
||||
"h-11 rounded-full px-5",
|
||||
)}
|
||||
>
|
||||
<Link href="/planning" className="inline-flex items-center rounded-full border border-border/80 bg-card/84 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-colors hover:bg-secondary">
|
||||
Open dagplanning
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -256,15 +212,9 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
|||
en eerste voorkeuren vast te leggen.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/onboarding"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "warning", size: "lg" }),
|
||||
"h-11 shrink-0 rounded-full px-5",
|
||||
)}
|
||||
>
|
||||
Rond onboarding af
|
||||
</Link>
|
||||
<Link href="/onboarding" className="inline-flex items-center rounded-full bg-warning px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-colors hover:brightness-[0.98]">
|
||||
Rond onboarding af
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
|
|
@ -277,19 +227,13 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
|||
timezone en zichtbaarheid van punten later zelfstandig kunt aanpassen.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/settings"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "secondary", size: "lg" }),
|
||||
"h-11 shrink-0 rounded-full px-5",
|
||||
)}
|
||||
>
|
||||
Open instellingen
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Link href="/settings" className="inline-flex items-center rounded-full bg-secondary px-4 py-2 text-sm font-medium text-secondary-foreground shadow-[var(--shadow-1)] transition-colors hover:brightness-[0.98]">
|
||||
Open instellingen
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
|
||||
import { AppShell } from "@/components/navigation/app-shell";
|
||||
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||
import { sanitizeNextPath } from "@/lib/auth/navigation";
|
||||
import { getAuthState } from "@/lib/auth/session";
|
||||
|
|
@ -41,11 +42,11 @@ export default async function OnboardingPage({ searchParams }: OnboardingPagePro
|
|||
);
|
||||
|
||||
return (
|
||||
<main className="app-page">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-8">
|
||||
<AppShell contentClassName="space-y-8">
|
||||
<div className="space-y-8">
|
||||
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
|
||||
<OnboardingFlow profileBundle={profileBundle} />
|
||||
</div>
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
194
app/page.tsx
194
app/page.tsx
|
|
@ -1,7 +1,7 @@
|
|||
import Link from "next/link";
|
||||
import { signOutAction } from "@/app/auth-actions";
|
||||
import { AppShell } from "@/components/navigation/app-shell";
|
||||
import { PageIntro } from "@/components/navigation/page-intro";
|
||||
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -9,14 +9,12 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { getAuthState } from "@/lib/auth/session";
|
||||
import { getAuthStatusToast } from "@/lib/feedback/status-messages";
|
||||
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const loopSteps = [
|
||||
const productLoop = [
|
||||
{
|
||||
title: "Check-in",
|
||||
copy: "Start de dag met een korte energiescore en slaapkwaliteit, zonder overbodige frictie.",
|
||||
|
|
@ -31,8 +29,15 @@ const loopSteps = [
|
|||
},
|
||||
];
|
||||
|
||||
const releaseFocus = [
|
||||
const makerNotes = [
|
||||
"Jan Peter Visser ontwikkelt deze app als rustige, praktische dagtool.",
|
||||
"De app is bewust gericht op helderheid, lage cognitieve belasting en een wellness-first toon.",
|
||||
"Elke stap wordt klein gehouden zodat de flow bruikbaar blijft zonder medische framing.",
|
||||
];
|
||||
|
||||
const appSpecs = [
|
||||
"Alleen individuele gebruikers in release 1",
|
||||
"Volwassen doelgroep en Nederlands als voertaal",
|
||||
"Wellness/self-management positionering",
|
||||
"Geen sharing, AI of medische workflows in de MVP",
|
||||
"Vercel + Supabase als technische basis",
|
||||
|
|
@ -43,7 +48,6 @@ type HomePageProps = {
|
|||
};
|
||||
|
||||
export default async function Home({ searchParams }: HomePageProps) {
|
||||
const authState = await getAuthState();
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const statusToast = getAuthStatusToast(
|
||||
getParamValue(resolvedSearchParams, "error"),
|
||||
|
|
@ -51,95 +55,38 @@ export default async function Home({ searchParams }: HomePageProps) {
|
|||
);
|
||||
|
||||
return (
|
||||
<main className="app-page">
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-10 sm:px-8 lg:px-10">
|
||||
<header className="mb-10 flex items-center justify-between border-b border-border/70 pb-5">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||
Inspannings Monitor
|
||||
</p>
|
||||
<h1 className="font-[family-name:var(--font-display)] text-3xl leading-tight sm:text-5xl">
|
||||
Rustige basis voor een wellness-first MVP
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
{authState.isConfigured ? (
|
||||
authState.isAuthenticated ? (
|
||||
<>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "lg" }),
|
||||
"h-11 shrink-0 whitespace-nowrap rounded-full px-5",
|
||||
)}
|
||||
>
|
||||
Naar dashboard
|
||||
</Link>
|
||||
<form action={signOutAction}>
|
||||
<Button type="submit" variant="outline" size="lg" className="h-11 shrink-0 whitespace-nowrap rounded-full px-5">
|
||||
Uitloggen
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "lg" }),
|
||||
"h-11 shrink-0 whitespace-nowrap rounded-full px-5",
|
||||
)}
|
||||
>
|
||||
Inloggen
|
||||
</Link>
|
||||
<Link
|
||||
href="/sign-up"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "lg" }),
|
||||
"h-11 shrink-0 whitespace-nowrap rounded-full px-5",
|
||||
)}
|
||||
>
|
||||
Account aanmaken
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<span className="rounded-full border border-warning/30 bg-warning/14 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)]">
|
||||
Supabase nog niet geconfigureerd
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AppShell contentClassName="space-y-8">
|
||||
<div className="space-y-8">
|
||||
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-[1.35fr_0.95fr]">
|
||||
<Card elevation="raised" className="rounded-[var(--radius-4xl)] py-0 backdrop-blur">
|
||||
<PageIntro
|
||||
eyebrow="About"
|
||||
title="Over de maker en de app"
|
||||
description="Inspannings Monitor is een rustige wellness-first webapp voor volwassenen die hun energie willen plannen, uitvoeren en evalueren zonder medische claims of overmatige frictie."
|
||||
aside={
|
||||
<Link
|
||||
href="/planning"
|
||||
className="inline-flex items-center rounded-full border border-border/80 bg-card/84 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-colors hover:bg-secondary"
|
||||
>
|
||||
Bekijk planning
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<Card elevation="raised" className="py-0">
|
||||
<CardContent className="p-6 sm:p-8">
|
||||
<p className="mb-4 max-w-2xl text-lg leading-8 text-muted-foreground">
|
||||
De projectbasis staat nu, inclusief de eerste auth-laag via Supabase.
|
||||
Release 1 blijft bewust smal: publieke landing, aparte login/signup
|
||||
routes en een eerste protected dashboard als basis voor de volgende stories.
|
||||
<p className="mb-5 max-w-2xl text-lg leading-8 text-muted-foreground">
|
||||
Deze app wordt ontwikkeld door Jan Peter Visser als compacte dagtool
|
||||
voor energieplanning en zelfregie. Het doel is niet om te diagnosticeren
|
||||
of te behandelen, maar om een rustige plan-doe-evalueer-structuur te bieden
|
||||
die licht genoeg blijft voor dagelijks gebruik.
|
||||
</p>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{loopSteps.map((step, index) => (
|
||||
<Card
|
||||
key={step.title}
|
||||
tone="subtle"
|
||||
className="rounded-[var(--radius-2xl)] py-0 shadow-none"
|
||||
>
|
||||
<CardHeader className="pb-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Stap {index + 1}
|
||||
</p>
|
||||
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
|
||||
{step.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-5">
|
||||
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||
{step.copy}
|
||||
</CardDescription>
|
||||
<div className="grid gap-4">
|
||||
{makerNotes.map((note) => (
|
||||
<Card key={note} tone="subtle" className="py-0 shadow-none">
|
||||
<CardContent className="px-5 py-4 text-sm leading-7 text-muted-foreground">
|
||||
{note}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
|
@ -147,14 +94,14 @@ export default async function Home({ searchParams }: HomePageProps) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card tone="primary" elevation="raised" className="rounded-[var(--radius-4xl)] py-0">
|
||||
<Card tone="primary" elevation="raised" className="py-0">
|
||||
<CardHeader className="px-6 pt-7 sm:px-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/75">
|
||||
Release 1 focus
|
||||
Specificaties van de app
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-6 pb-7 sm:px-8">
|
||||
{releaseFocus.map((item) => (
|
||||
{appSpecs.map((item) => (
|
||||
<Card
|
||||
key={item}
|
||||
className="rounded-[var(--radius-2xl)] border-white/10 bg-white/8 py-0 text-primary-foreground shadow-none"
|
||||
|
|
@ -162,26 +109,49 @@ export default async function Home({ searchParams }: HomePageProps) {
|
|||
<CardContent className="px-4 py-3 text-sm leading-7">{item}</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{authState.isConfigured ? (
|
||||
<p className="pt-2 text-sm leading-7 text-primary-foreground/80">
|
||||
Auth is ingericht met e-mail, wachtwoord en verplichte e-mailverificatie.
|
||||
</p>
|
||||
) : (
|
||||
<p className="pt-2 text-sm leading-7 text-primary-foreground/80">
|
||||
Voeg `.env.local` toe om login, signup en protected routes lokaal te activeren.
|
||||
</p>
|
||||
)}
|
||||
<p className="pt-2 text-sm leading-7 text-primary-foreground/80">
|
||||
De huidige codebasis bevat al auth, onboarding, ochtendcheck-in,
|
||||
planning, energiemeter en Dusk-theming.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card tone="subtle" className="mt-8 rounded-[var(--radius-4xl)] py-0 backdrop-blur">
|
||||
<Card elevation="raised" className="py-0">
|
||||
<CardHeader className="pb-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||
Dagflow
|
||||
</p>
|
||||
<CardTitle className="text-2xl">De hoofdstructuur van release 1</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-5 p-6 md:grid-cols-3">
|
||||
{productLoop.map((step, index) => (
|
||||
<Card key={step.title} tone="subtle" className="py-0 shadow-none">
|
||||
<CardHeader className="pb-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Stap {index + 1}
|
||||
</p>
|
||||
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
|
||||
{step.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-5">
|
||||
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||
{step.copy}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card tone="subtle" className="py-0 backdrop-blur">
|
||||
<CardContent className="grid gap-5 p-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||
Volgende story
|
||||
Positionering
|
||||
</p>
|
||||
<p className="mt-2 font-semibold text-foreground">ST-201 Ochtendcheck-in</p>
|
||||
<p className="mt-2 font-semibold text-foreground">Wellness / self-management</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||
|
|
@ -191,19 +161,19 @@ export default async function Home({ searchParams }: HomePageProps) {
|
|||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||
Positionering
|
||||
Auth en data
|
||||
</p>
|
||||
<p className="mt-2 font-semibold text-foreground">Wellness / self-management</p>
|
||||
<p className="mt-2 font-semibold text-foreground">Supabase Auth + PostgreSQL</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||
Status
|
||||
Hosting
|
||||
</p>
|
||||
<p className="mt-2 font-semibold text-foreground">Auth, onboarding en settings actief</p>
|
||||
<p className="mt-2 font-semibold text-foreground">Next.js op Vercel</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { signOutAction } from "@/app/auth-actions";
|
||||
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
|
||||
import { AppShell } from "@/components/navigation/app-shell";
|
||||
import { PageIntro } from "@/components/navigation/page-intro";
|
||||
import { ActivityForm } from "@/components/planning/activity-form";
|
||||
import { EnergyMeterCard } from "@/components/planning/energy-meter-card";
|
||||
import { TodayActivitiesList } from "@/components/planning/today-activities-list";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -21,7 +21,6 @@ import { getPlanningPageDataForCurrentUser } from "@/lib/planning/service";
|
|||
import { calculatePlanningMeterSnapshot } from "@/lib/planning/meter";
|
||||
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
|
||||
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -70,45 +69,23 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps)
|
|||
);
|
||||
|
||||
return (
|
||||
<main className="app-page">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-8">
|
||||
<AppShell contentClassName="space-y-8">
|
||||
<div className="space-y-8">
|
||||
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
|
||||
|
||||
<header className="app-page-header">
|
||||
<div>
|
||||
<div className="app-page-breadcrumb">
|
||||
<Link href="/dashboard" className="app-page-link">
|
||||
Dashboard
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span>Dagplanning</span>
|
||||
</div>
|
||||
<h1 className="mt-3 font-[family-name:var(--font-display)] text-4xl leading-tight">
|
||||
Plan vandaag bewust klein
|
||||
</h1>
|
||||
<p className="app-page-copy">
|
||||
Voeg alleen activiteiten toe die vandaag echt relevant zijn. Houd de lijst licht,
|
||||
zodat je later goed kunt bijsturen zonder druk op te bouwen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<PageIntro
|
||||
eyebrow="Planning"
|
||||
title="Plan vandaag bewust klein"
|
||||
description="Voeg alleen activiteiten toe die vandaag echt relevant zijn. Houd de lijst licht, zodat je later goed kunt bijsturen zonder druk op te bouwen."
|
||||
aside={
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "lg" }),
|
||||
"h-11 rounded-full px-5",
|
||||
)}
|
||||
className="inline-flex items-center rounded-full border border-border/80 bg-card/84 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-colors hover:bg-secondary"
|
||||
>
|
||||
Terug naar dashboard
|
||||
</Link>
|
||||
<form action={signOutAction}>
|
||||
<Button type="submit" size="lg" className="h-11 rounded-full px-5">
|
||||
Uitloggen
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<ActivityForm
|
||||
|
|
@ -158,7 +135,7 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps)
|
|||
<CardContent className="space-y-3 pb-6 text-sm leading-7 text-primary-foreground/90">
|
||||
<p>Deze planning blokkeert je niet en geeft nog geen harde waarschuwingen.</p>
|
||||
<p>Je meter gebruikt een eenvoudige, uitlegbare afleiding uit duur en impact.</p>
|
||||
<p>Niet-blokkerende overschrijdingsfeedback volgt in `ST-305`.</p>
|
||||
<p>Bij overschrijding krijg je nu een warme, niet-blokkerende waarschuwing in plaats van een harde blokkade.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
|
|
@ -169,6 +146,6 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps)
|
|||
categories={planningPageData.categories}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { signOutAction } from "@/app/auth-actions";
|
||||
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
|
||||
import { AppShell } from "@/components/navigation/app-shell";
|
||||
import { PageIntro } from "@/components/navigation/page-intro";
|
||||
import { SettingsForm } from "@/components/settings/settings-form";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -16,7 +16,6 @@ import { getAuthState } from "@/lib/auth/session";
|
|||
import { getSettingsStatusToast } from "@/lib/feedback/status-messages";
|
||||
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
|
||||
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -57,45 +56,23 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps)
|
|||
"Ingelogde gebruiker";
|
||||
|
||||
return (
|
||||
<main className="app-page">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-8">
|
||||
<AppShell contentClassName="space-y-8">
|
||||
<div className="space-y-8">
|
||||
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
|
||||
|
||||
<header className="app-page-header">
|
||||
<div>
|
||||
<div className="app-page-breadcrumb">
|
||||
<Link href="/dashboard" className="app-page-link">
|
||||
Dashboard
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span>Instellingen</span>
|
||||
</div>
|
||||
<h1 className="mt-3 font-[family-name:var(--font-display)] text-4xl leading-tight">
|
||||
Instellingen
|
||||
</h1>
|
||||
<p className="app-page-copy">
|
||||
Pas je basisvoorkeuren rustig aan. Alles blijft beperkt tot jouw eigen
|
||||
account en de wellness-first scope van release 1.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<PageIntro
|
||||
eyebrow="Instellingen"
|
||||
title="Basisinstellingen voor jouw account"
|
||||
description="Pas je basisvoorkeuren rustig aan. Alles blijft beperkt tot jouw eigen account en de wellness-first scope van release 1."
|
||||
aside={
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "lg" }),
|
||||
"h-11 rounded-full px-5",
|
||||
)}
|
||||
className="inline-flex items-center rounded-full border border-border/80 bg-card/84 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-colors hover:bg-secondary"
|
||||
>
|
||||
Terug naar dashboard
|
||||
</Link>
|
||||
<form action={signOutAction}>
|
||||
<Button type="submit" size="lg" className="h-11 rounded-full px-5">
|
||||
Uitloggen
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<SettingsForm profileBundle={profileBundle} />
|
||||
|
|
@ -134,6 +111,6 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps)
|
|||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { AppShell } from "@/components/navigation/app-shell";
|
||||
import { TestWizardFlow } from "@/components/wizard/test-wizard-flow";
|
||||
import { sanitizeNextPath } from "@/lib/auth/navigation";
|
||||
import { getAuthState } from "@/lib/auth/session";
|
||||
|
|
@ -22,10 +23,10 @@ export default async function WizardTestPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<main className="app-page">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-8">
|
||||
<AppShell contentClassName="space-y-8">
|
||||
<div className="space-y-8">
|
||||
<TestWizardFlow />
|
||||
</div>
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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