Merge pull request #2 from madhura68/feat/avatar-bottom-nav
Feat/avatar bottom nav
This commit is contained in:
commit
aca34c57e6
43 changed files with 842 additions and 219 deletions
58
CLAUDE.md
58
CLAUDE.md
|
|
@ -10,28 +10,28 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Start dev server (localhost:3000)
|
npm run dev # Start dev server (localhost:3000)
|
||||||
npm run build # Production build
|
npm run build # Production build --webpack (rolldown disabled)
|
||||||
npm run lint # ESLint
|
npm run lint # ESLint
|
||||||
|
npm run test # Vitest (unit tests only)
|
||||||
```
|
```
|
||||||
|
|
||||||
No test framework is configured yet.
|
Node version: `>=20.19.0` (see `.nvmrc` / `engines` in package.json).
|
||||||
|
|
||||||
Node version: `20.9.0` (see `.nvmrc`).
|
|
||||||
|
|
||||||
## Environment Setup
|
## Environment Setup
|
||||||
|
|
||||||
Copy `.env.example` to `.env.local` and fill in your Supabase project values:
|
Copy `.env.example` to `.env.local`:
|
||||||
|
|
||||||
```
|
```
|
||||||
NEXT_PUBLIC_SUPABASE_URL=
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
|
||||||
|
NEXT_PUBLIC_ENABLE_TEST_WIZARD=true # optional: enables /wizard-test route
|
||||||
```
|
```
|
||||||
|
|
||||||
Supabase project must have email/password auth enabled with email confirmation. Apply migrations from `supabase/migrations/` to your local/remote DB.
|
Supabase project must have email/password auth enabled with email confirmation. Apply migrations from `supabase/migrations/` to your local/remote DB.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
**Stack:** Next.js (App Router) + React 19 + TypeScript + Supabase (Auth + PostgreSQL) + shadcn/ui + Tailwind CSS. Deployed on Vercel.
|
**Stack:** Next.js 16 (App Router) + React 19 + TypeScript + Supabase (Auth + PostgreSQL) + shadcn/ui + Tailwind CSS. Deployed on Vercel.
|
||||||
|
|
||||||
### Route structure
|
### Route structure
|
||||||
|
|
||||||
|
|
@ -42,21 +42,37 @@ Supabase project must have email/password auth enabled with email confirmation.
|
||||||
| `/auth/confirm` | Email confirmation callback |
|
| `/auth/confirm` | Email confirmation callback |
|
||||||
| `/onboarding` | Mandatory first-time setup |
|
| `/onboarding` | Mandatory first-time setup |
|
||||||
| `/dashboard` | Main protected page |
|
| `/dashboard` | Main protected page |
|
||||||
|
| `/planning` | Daily activity planning |
|
||||||
|
| `/check-in` | Morning check-in flow |
|
||||||
| `/settings` | User preferences |
|
| `/settings` | User preferences |
|
||||||
|
| `/wizard-test` | Internal wizard UI test (feature-flagged) |
|
||||||
|
|
||||||
### Auth & data flow
|
### Auth & data flow
|
||||||
|
|
||||||
- `lib/auth/` — `getAuthState()` validates the session server-side from SSR cookies. All protected routes call this and redirect unauthenticated users to `/login`.
|
- `lib/auth/session.ts` — `getAuthState()` validates the session server-side from SSR cookies. All protected pages call this and redirect unauthenticated users to `/login`.
|
||||||
- New users are redirected to `/onboarding`; dashboard redirects there if onboarding is incomplete.
|
- New users are redirected to `/onboarding`; the dashboard redirects there if onboarding is incomplete.
|
||||||
- On first protected page load, `profiles` and `user_settings` rows are auto-created with defaults if missing.
|
- On first protected page load, `profiles` and `user_settings` rows are auto-created with defaults if missing (`lib/profile/service.ts`).
|
||||||
- Server Actions (`app/**/actions.ts`) handle form mutations; client components call these directly.
|
- Server Actions (`app/**/actions.ts`) handle all form mutations; client components call these directly.
|
||||||
|
- Status feedback after redirects flows via `?status=<key>` search params → `lib/feedback/status-messages.ts` → `StatusToastBridge` component (Sonner toasts).
|
||||||
|
|
||||||
|
### Energy model
|
||||||
|
|
||||||
|
The core domain logic lives in `lib/check-in/` and `lib/planning/`:
|
||||||
|
|
||||||
|
- **Morning check-in** (`lib/check-in/`) — user scores energy 1–10 and sleep quality. `budget.ts` derives `energyLevel` and `dailyBudget` (budget = score, formula versioned via `BUDGET_FORMULA_VERSION`).
|
||||||
|
- **Activity energy points** (`lib/planning/meter.ts`) — each activity scores points from duration band (1–4) × impact adjustment. The planning meter tracks `actualPoints` vs `dailyBudget`.
|
||||||
|
- **Day overview** (`lib/planning/day-overview.ts`) — aggregates activity counts and planned vs actual points for dashboard display.
|
||||||
|
- **Activity statuses**: `planned` → `completed` / `adjusted` / `skipped`. Ad-hoc activities can be added directly.
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
|
|
||||||
Two tables with Row Level Security (users see only their own rows):
|
Tables with Row Level Security (users see only their own rows):
|
||||||
|
|
||||||
- **`profiles`** — display name, locale, timezone, onboarding completion flags
|
- **`profiles`** — display name, locale, timezone, avatar, onboarding flags
|
||||||
- **`user_settings`** — reminder preferences, energy point visibility
|
- **`user_settings`** — reminder preferences, energy point visibility
|
||||||
|
- **`morning_check_ins`** — per-day energy score, sleep quality, derived budget/level
|
||||||
|
- **`activities`** — daily activity records with status, duration, impact, priority, category
|
||||||
|
- **`activity_categories`** / **`skip_reasons`** — reference data (seeded)
|
||||||
|
|
||||||
Migrations live in `supabase/migrations/`.
|
Migrations live in `supabase/migrations/`.
|
||||||
|
|
||||||
|
|
@ -65,12 +81,24 @@ Migrations live in `supabase/migrations/`.
|
||||||
- `lib/supabase/` — Supabase client setup (server-side SSR client + proxy config)
|
- `lib/supabase/` — Supabase client setup (server-side SSR client + proxy config)
|
||||||
- `lib/auth/` — session helpers, navigation utilities, Dutch error messages
|
- `lib/auth/` — session helpers, navigation utilities, Dutch error messages
|
||||||
- `lib/profile/service.ts` — CRUD for profiles and user_settings
|
- `lib/profile/service.ts` — CRUD for profiles and user_settings
|
||||||
- `lib/profile/types.ts` — shared TypeScript types for profile/settings data
|
- `lib/profile/types.ts` — shared TypeScript types
|
||||||
- `lib/onboarding/` — onboarding options and timezone lists
|
- `lib/forms/parse.ts` — typed FormData helpers (`getRequiredString`, `getEnumValue`, `getIntegerValue`, etc.) used in all Server Actions; throws `FormDataValidationError` with an error code string
|
||||||
|
- `lib/feedback/status-messages.ts` — maps `?status=` param keys to `StatusToast` objects
|
||||||
|
- `lib/wizard/` — generic multi-step wizard primitives (`WizardStepDefinition`, `useWizardFlow` hook); used by onboarding and check-in flows
|
||||||
|
- `lib/search-params.ts` — `getParamValue()` helper for server page `searchParams`
|
||||||
|
- `lib/config/feature-flags.ts` — `isTestWizardEnabled()` reads `NEXT_PUBLIC_ENABLE_TEST_WIZARD`
|
||||||
|
|
||||||
### UI components
|
### UI components
|
||||||
|
|
||||||
`components/ui/` contains shadcn/ui primitives (button, card, input, select, alert, etc.). Feature-level components live in `components/auth/`, `components/onboarding/`, and `components/settings/`. Path alias `@/*` resolves from the repo root.
|
`components/ui/` contains shadcn/ui primitives. Feature-level components live in `components/auth/`, `components/check-in/`, `components/planning/`, `components/onboarding/`, `components/settings/`, `components/wizard/`, `components/navigation/`, `components/feedback/`, and `components/profile/`. Path alias `@/*` resolves from the repo root.
|
||||||
|
|
||||||
|
The app uses the **Dusk theme** — dark-mode-first, semantic surface tokens, enhanced focus/a11y styles (`app/globals.css`).
|
||||||
|
|
||||||
|
### Seeding
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run seed:demo-users # seeds demo personas via scripts/seed-demo-users.mjs
|
||||||
|
```
|
||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export default function CheckInLoading() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
|
<div className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardContent className="space-y-5 pb-6 pt-4">
|
<CardContent className="space-y-5 pb-6 pt-4">
|
||||||
<Skeleton className="h-4 w-32" />
|
<Skeleton className="h-4 w-32" />
|
||||||
<Skeleton className="h-8 w-full" />
|
<Skeleton className="h-8 w-full" />
|
||||||
|
|
@ -36,7 +36,7 @@ export default function CheckInLoading() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<Skeleton className="h-3 w-16" />
|
<Skeleton className="h-3 w-16" />
|
||||||
<Skeleton className="mt-1 h-5 w-48" />
|
<Skeleton className="mt-1 h-5 w-48" />
|
||||||
|
|
@ -46,7 +46,7 @@ export default function CheckInLoading() {
|
||||||
<Skeleton className="mt-1.5 h-4 w-3/4" />
|
<Skeleton className="mt-1.5 h-4 w-3/4" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<Skeleton className="h-3 w-24" />
|
<Skeleton className="h-3 w-24" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export default async function CheckInPage({ searchParams }: CheckInPageProps) {
|
||||||
<CheckInForm todayCheckIn={checkInStatus?.todayCheckIn ?? null} />
|
<CheckInForm todayCheckIn={checkInStatus?.todayCheckIn ?? null} />
|
||||||
|
|
||||||
<aside className="space-y-5">
|
<aside className="space-y-5">
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Vandaag
|
Vandaag
|
||||||
|
|
@ -100,7 +100,7 @@ export default async function CheckInPage({ searchParams }: CheckInPageProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card tone="primary" elevation="raised" className="py-0">
|
<Card tone="primary" elevation="raised" className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/75">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/75">
|
||||||
Bewuste grens
|
Bewuste grens
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export default function DashboardLoading() {
|
||||||
|
|
||||||
<div className="grid gap-5 md:grid-cols-3">
|
<div className="grid gap-5 md:grid-cols-3">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<Card key={i} className="py-0">
|
<Card key={i} className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<Skeleton className="h-3 w-14" />
|
<Skeleton className="h-3 w-14" />
|
||||||
<Skeleton className="mt-1 h-5 w-40" />
|
<Skeleton className="mt-1 h-5 w-40" />
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="grid gap-5 md:grid-cols-3">
|
<section className="grid gap-5 md:grid-cols-3">
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Auth
|
Auth
|
||||||
|
|
@ -112,7 +112,7 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Profiel
|
Profiel
|
||||||
|
|
@ -145,7 +145,7 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Onboarding
|
Onboarding
|
||||||
|
|
@ -160,7 +160,7 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Instellingen
|
Instellingen
|
||||||
|
|
@ -179,7 +179,7 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
||||||
|
|
||||||
<CheckInCard todayCheckIn={checkInStatus?.todayCheckIn ?? null} />
|
<CheckInCard todayCheckIn={checkInStatus?.todayCheckIn ?? null} />
|
||||||
|
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Dagplanning
|
Dagplanning
|
||||||
|
|
@ -205,7 +205,7 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
||||||
<EnergyMeterCard meter={planningMeter} tone="subtle" />
|
<EnergyMeterCard meter={planningMeter} tone="subtle" />
|
||||||
|
|
||||||
{isTestWizardEnabled() ? (
|
{isTestWizardEnabled() ? (
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Wizard core
|
Wizard core
|
||||||
|
|
@ -222,7 +222,7 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{!profile.onboardingCompleted ? (
|
{!profile.onboardingCompleted ? (
|
||||||
<Card className="border-warning/32 bg-warning/16 py-0 text-foreground shadow-[var(--shadow-1)]">
|
<Card className="border-warning/32 bg-warning/16 pb-0 text-foreground shadow-[var(--shadow-1)]">
|
||||||
<CardContent className="flex flex-col gap-4 px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
|
<CardContent className="flex flex-col gap-4 px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Je onboarding is nog niet afgerond.</p>
|
<p className="font-semibold">Je onboarding is nog niet afgerond.</p>
|
||||||
|
|
@ -237,7 +237,7 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card tone="primary" elevation="raised" className="py-0">
|
<Card tone="primary" elevation="raised" className="pb-0">
|
||||||
<CardContent className="flex flex-col gap-4 px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
|
<CardContent className="flex flex-col gap-4 px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Je instellingen kun je nu ook los beheren.</p>
|
<p className="font-semibold">Je instellingen kun je nu ook los beheren.</p>
|
||||||
|
|
|
||||||
164
app/globals.css
164
app/globals.css
|
|
@ -8,6 +8,28 @@
|
||||||
--font-body: var(--font-inter-tight), ui-sans-serif, system-ui, sans-serif;
|
--font-body: var(--font-inter-tight), ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-display: var(--font-body);
|
--font-display: var(--font-body);
|
||||||
--font-mono: var(--font-plex-mono), ui-monospace, monospace;
|
--font-mono: var(--font-plex-mono), ui-monospace, monospace;
|
||||||
|
--typescale-display-large-size: clamp(2.625rem, 5vw, 3.5625rem);
|
||||||
|
--typescale-display-large-line-height: 1.08;
|
||||||
|
--typescale-display-medium-size: clamp(2.125rem, 4vw, 2.8125rem);
|
||||||
|
--typescale-display-medium-line-height: 1.12;
|
||||||
|
--typescale-headline-large-size: clamp(1.375rem, 3vw, 2rem);
|
||||||
|
--typescale-headline-large-line-height: 1.2;
|
||||||
|
--typescale-headline-medium-size: 1.375rem;
|
||||||
|
--typescale-headline-medium-line-height: 1.2;
|
||||||
|
--typescale-title-large-size: 1rem;
|
||||||
|
--typescale-title-large-line-height: 1.3;
|
||||||
|
--typescale-title-medium-size: 0.875rem;
|
||||||
|
--typescale-title-medium-line-height: 1.3;
|
||||||
|
--typescale-body-large-size: 1rem;
|
||||||
|
--typescale-body-large-line-height: 1.65;
|
||||||
|
--typescale-body-medium-size: 0.875rem;
|
||||||
|
--typescale-body-medium-line-height: 1.65;
|
||||||
|
--typescale-label-large-size: 0.875rem;
|
||||||
|
--typescale-label-large-line-height: 1.4;
|
||||||
|
--typescale-label-medium-size: 0.75rem;
|
||||||
|
--typescale-label-medium-line-height: 1.4;
|
||||||
|
--typescale-label-small-size: 0.6875rem;
|
||||||
|
--typescale-label-small-line-height: 1.4;
|
||||||
|
|
||||||
--background: oklch(97% 0.008 80);
|
--background: oklch(97% 0.008 80);
|
||||||
--foreground: oklch(22% 0.03 262);
|
--foreground: oklch(22% 0.03 262);
|
||||||
|
|
@ -18,12 +40,24 @@
|
||||||
|
|
||||||
--primary: oklch(44% 0.11 262);
|
--primary: oklch(44% 0.11 262);
|
||||||
--primary-foreground: oklch(98% 0.01 262);
|
--primary-foreground: oklch(98% 0.01 262);
|
||||||
|
--primary-container: oklch(90% 0.03 262);
|
||||||
|
--primary-container-foreground: oklch(28% 0.06 262);
|
||||||
--secondary: oklch(92% 0.03 262);
|
--secondary: oklch(92% 0.03 262);
|
||||||
--secondary-foreground: oklch(44% 0.11 262);
|
--secondary-foreground: oklch(44% 0.11 262);
|
||||||
|
--secondary-container: oklch(94% 0.018 262);
|
||||||
|
--secondary-container-foreground: oklch(32% 0.05 262);
|
||||||
|
--tertiary: oklch(56% 0.09 200);
|
||||||
|
--tertiary-foreground: oklch(98% 0.006 200);
|
||||||
|
--tertiary-container: oklch(91% 0.025 200);
|
||||||
|
--tertiary-container-foreground: oklch(29% 0.055 200);
|
||||||
--muted: oklch(95% 0.012 82);
|
--muted: oklch(95% 0.012 82);
|
||||||
--muted-foreground: oklch(58% 0.015 262);
|
--muted-foreground: oklch(58% 0.015 262);
|
||||||
--accent: oklch(92% 0.03 262);
|
--accent: oklch(92% 0.03 262);
|
||||||
--accent-foreground: oklch(44% 0.11 262);
|
--accent-foreground: oklch(44% 0.11 262);
|
||||||
|
--surface-container-low: oklch(98.5% 0.004 80);
|
||||||
|
--surface-container: oklch(97.4% 0.007 80);
|
||||||
|
--surface-container-high: oklch(95.8% 0.012 80);
|
||||||
|
--outline-variant: oklch(76% 0.012 262 / 0.4);
|
||||||
|
|
||||||
--destructive: oklch(58% 0.16 25);
|
--destructive: oklch(58% 0.16 25);
|
||||||
--success: oklch(62% 0.09 155);
|
--success: oklch(62% 0.09 155);
|
||||||
|
|
@ -91,6 +125,18 @@ a {
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
--color-warning: var(--warning);
|
--color-warning: var(--warning);
|
||||||
--color-success: var(--success);
|
--color-success: var(--success);
|
||||||
|
--color-outline-variant: var(--outline-variant);
|
||||||
|
--color-surface-container-high: var(--surface-container-high);
|
||||||
|
--color-surface-container: var(--surface-container);
|
||||||
|
--color-surface-container-low: var(--surface-container-low);
|
||||||
|
--color-tertiary-container-foreground: var(--tertiary-container-foreground);
|
||||||
|
--color-tertiary-container: var(--tertiary-container);
|
||||||
|
--color-tertiary-foreground: var(--tertiary-foreground);
|
||||||
|
--color-tertiary: var(--tertiary);
|
||||||
|
--color-secondary-container-foreground: var(--secondary-container-foreground);
|
||||||
|
--color-secondary-container: var(--secondary-container);
|
||||||
|
--color-primary-container-foreground: var(--primary-container-foreground);
|
||||||
|
--color-primary-container: var(--primary-container);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
|
|
@ -110,8 +156,9 @@ a {
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--font-mono: var(--font-plex-mono), ui-monospace, monospace;
|
--font-mono: var(--font-plex-mono), ui-monospace, monospace;
|
||||||
|
--radius-xs: 4px;
|
||||||
--radius-sm: 8px;
|
--radius-sm: 8px;
|
||||||
--radius-md: 11px;
|
--radius-md: 12px;
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: 21px;
|
--radius-xl: 21px;
|
||||||
--radius-2xl: 25px;
|
--radius-2xl: 25px;
|
||||||
|
|
@ -130,12 +177,24 @@ a {
|
||||||
|
|
||||||
--primary: oklch(52% 0.13 262);
|
--primary: oklch(52% 0.13 262);
|
||||||
--primary-foreground: oklch(98% 0.01 262);
|
--primary-foreground: oklch(98% 0.01 262);
|
||||||
|
--primary-container: oklch(30% 0.06 262);
|
||||||
|
--primary-container-foreground: oklch(94% 0.012 262);
|
||||||
--secondary: oklch(28% 0.03 262);
|
--secondary: oklch(28% 0.03 262);
|
||||||
--secondary-foreground: oklch(96% 0.008 80);
|
--secondary-foreground: oklch(96% 0.008 80);
|
||||||
|
--secondary-container: oklch(31% 0.028 262);
|
||||||
|
--secondary-container-foreground: oklch(93% 0.01 262);
|
||||||
|
--tertiary: oklch(68% 0.09 200);
|
||||||
|
--tertiary-foreground: oklch(16% 0.025 200);
|
||||||
|
--tertiary-container: oklch(32% 0.05 200);
|
||||||
|
--tertiary-container-foreground: oklch(94% 0.012 200);
|
||||||
--muted: oklch(26% 0.025 262);
|
--muted: oklch(26% 0.025 262);
|
||||||
--muted-foreground: oklch(70% 0.015 262);
|
--muted-foreground: oklch(70% 0.015 262);
|
||||||
--accent: oklch(30% 0.04 262);
|
--accent: oklch(30% 0.04 262);
|
||||||
--accent-foreground: oklch(96% 0.008 80);
|
--accent-foreground: oklch(96% 0.008 80);
|
||||||
|
--surface-container-low: oklch(20% 0.022 262);
|
||||||
|
--surface-container: oklch(23% 0.024 262);
|
||||||
|
--surface-container-high: oklch(27% 0.026 262);
|
||||||
|
--outline-variant: oklch(68% 0.014 262 / 0.36);
|
||||||
|
|
||||||
--destructive: oklch(70% 0.16 25);
|
--destructive: oklch(70% 0.16 25);
|
||||||
--success: oklch(74% 0.09 155);
|
--success: oklch(74% 0.09 155);
|
||||||
|
|
@ -259,6 +318,109 @@ a {
|
||||||
@apply transition hover:text-foreground;
|
@apply transition hover:text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.type-display-large {
|
||||||
|
font-size: var(--typescale-display-large-size);
|
||||||
|
line-height: var(--typescale-display-large-line-height);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-display-medium {
|
||||||
|
font-size: var(--typescale-display-medium-size);
|
||||||
|
line-height: var(--typescale-display-medium-line-height);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-headline-large {
|
||||||
|
font-size: var(--typescale-headline-large-size);
|
||||||
|
line-height: var(--typescale-headline-large-line-height);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-headline-medium {
|
||||||
|
font-size: var(--typescale-headline-medium-size);
|
||||||
|
line-height: var(--typescale-headline-medium-line-height);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-title-large {
|
||||||
|
font-size: var(--typescale-title-large-size);
|
||||||
|
line-height: var(--typescale-title-large-line-height);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-title-medium {
|
||||||
|
font-size: var(--typescale-title-medium-size);
|
||||||
|
line-height: var(--typescale-title-medium-line-height);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-body-large {
|
||||||
|
font-size: var(--typescale-body-large-size);
|
||||||
|
line-height: var(--typescale-body-large-line-height);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-body-medium {
|
||||||
|
font-size: var(--typescale-body-medium-size);
|
||||||
|
line-height: var(--typescale-body-medium-line-height);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-label-large {
|
||||||
|
font-size: var(--typescale-label-large-size);
|
||||||
|
line-height: var(--typescale-label-large-line-height);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-label-medium {
|
||||||
|
font-size: var(--typescale-label-medium-size);
|
||||||
|
line-height: var(--typescale-label-medium-line-height);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-label-small {
|
||||||
|
font-size: var(--typescale-label-small-size);
|
||||||
|
line-height: var(--typescale-label-small-line-height);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-layer {
|
||||||
|
--state-layer-opacity: 0;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
color-mix(in oklab, currentColor calc(var(--state-layer-opacity) * 100%), transparent),
|
||||||
|
color-mix(in oklab, currentColor calc(var(--state-layer-opacity) * 100%), transparent)
|
||||||
|
);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 100% 100%;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
box-shadow 160ms ease,
|
||||||
|
color 160ms ease,
|
||||||
|
transform 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-layer:hover {
|
||||||
|
--state-layer-opacity: 0.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-layer:focus-visible,
|
||||||
|
.state-layer:active {
|
||||||
|
--state-layer-opacity: 0.12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-layer:disabled,
|
||||||
|
.state-layer[data-disabled] {
|
||||||
|
--state-layer-opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.app-page-copy {
|
.app-page-copy {
|
||||||
@apply mt-4 max-w-2xl text-base leading-8 text-muted-foreground;
|
@apply mt-4 max-w-2xl text-base leading-8 text-muted-foreground;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
app/page.tsx
14
app/page.tsx
|
|
@ -74,7 +74,7 @@ export default async function Home({ searchParams }: HomePageProps) {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
<section className="grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
||||||
<Card elevation="raised" className="py-0">
|
<Card elevation="raised" className="pb-0">
|
||||||
<CardContent className="p-6 sm:p-8">
|
<CardContent className="p-6 sm:p-8">
|
||||||
<p className="mb-5 max-w-2xl text-lg leading-8 text-muted-foreground">
|
<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
|
Deze app wordt ontwikkeld door Jan Peter Visser als compacte dagtool
|
||||||
|
|
@ -84,7 +84,7 @@ export default async function Home({ searchParams }: HomePageProps) {
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{makerNotes.map((note) => (
|
{makerNotes.map((note) => (
|
||||||
<Card key={note} tone="subtle" className="py-0 shadow-none">
|
<Card key={note} tone="subtle" className="pb-0 shadow-none">
|
||||||
<CardContent className="px-5 py-4 text-sm leading-7 text-muted-foreground">
|
<CardContent className="px-5 py-4 text-sm leading-7 text-muted-foreground">
|
||||||
{note}
|
{note}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -94,7 +94,7 @@ export default async function Home({ searchParams }: HomePageProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card tone="primary" elevation="raised" className="py-0">
|
<Card tone="primary" elevation="raised" className="pb-0">
|
||||||
<CardHeader className="px-6 pt-7 sm:px-8">
|
<CardHeader className="px-6 pt-7 sm:px-8">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/75">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/75">
|
||||||
Specificaties van de app
|
Specificaties van de app
|
||||||
|
|
@ -104,7 +104,7 @@ export default async function Home({ searchParams }: HomePageProps) {
|
||||||
{appSpecs.map((item) => (
|
{appSpecs.map((item) => (
|
||||||
<Card
|
<Card
|
||||||
key={item}
|
key={item}
|
||||||
className="rounded-[var(--radius-2xl)] border-white/10 bg-white/8 py-0 text-primary-foreground shadow-none"
|
className="rounded-[var(--radius-2xl)] border-white/10 bg-white/8 pb-0 text-primary-foreground shadow-none"
|
||||||
>
|
>
|
||||||
<CardContent className="px-4 py-3 text-sm leading-7">{item}</CardContent>
|
<CardContent className="px-4 py-3 text-sm leading-7">{item}</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -117,7 +117,7 @@ export default async function Home({ searchParams }: HomePageProps) {
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Card elevation="raised" className="py-0">
|
<Card elevation="raised" className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Dagflow
|
Dagflow
|
||||||
|
|
@ -126,7 +126,7 @@ export default async function Home({ searchParams }: HomePageProps) {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-5 p-6 md:grid-cols-3">
|
<CardContent className="grid gap-5 p-6 md:grid-cols-3">
|
||||||
{productLoop.map((step, index) => (
|
{productLoop.map((step, index) => (
|
||||||
<Card key={step.title} tone="subtle" className="py-0 shadow-none">
|
<Card key={step.title} tone="subtle" className="pb-0 shadow-none">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||||
Stap {index + 1}
|
Stap {index + 1}
|
||||||
|
|
@ -145,7 +145,7 @@ export default async function Home({ searchParams }: HomePageProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card tone="subtle" className="py-0 backdrop-blur">
|
<Card tone="subtle" className="pb-0 backdrop-blur">
|
||||||
<CardContent className="grid gap-5 p-6 sm:grid-cols-2 lg:grid-cols-4">
|
<CardContent className="grid gap-5 p-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export default function PlanningLoading() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
|
<div className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardContent className="space-y-4 pb-6 pt-4">
|
<CardContent className="space-y-4 pb-6 pt-4">
|
||||||
<Skeleton className="h-4 w-32" />
|
<Skeleton className="h-4 w-32" />
|
||||||
<Skeleton className="h-10 w-full" />
|
<Skeleton className="h-10 w-full" />
|
||||||
|
|
@ -36,7 +36,7 @@ export default function PlanningLoading() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<Skeleton className="h-3 w-16" />
|
<Skeleton className="h-3 w-16" />
|
||||||
<Skeleton className="mt-1 h-5 w-48" />
|
<Skeleton className="mt-1 h-5 w-48" />
|
||||||
|
|
@ -46,14 +46,14 @@ export default function PlanningLoading() {
|
||||||
<Skeleton className="mt-1.5 h-4 w-3/4" />
|
<Skeleton className="mt-1.5 h-4 w-3/4" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardContent className="space-y-3 pb-6 pt-4">
|
<CardContent className="space-y-3 pb-6 pt-4">
|
||||||
<Skeleton className="h-4 w-32" />
|
<Skeleton className="h-4 w-32" />
|
||||||
<Skeleton className="h-5 w-full rounded-full" />
|
<Skeleton className="h-5 w-full rounded-full" />
|
||||||
<Skeleton className="h-3 w-24" />
|
<Skeleton className="h-3 w-24" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<Skeleton className="h-3 w-24" />
|
<Skeleton className="h-3 w-24" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -69,7 +69,7 @@ export default function PlanningLoading() {
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Skeleton className="h-5 w-48" />
|
<Skeleton className="h-5 w-48" />
|
||||||
{[0, 1, 2].map((i) => (
|
{[0, 1, 2].map((i) => (
|
||||||
<Card key={i} className="py-0">
|
<Card key={i} className="pb-0">
|
||||||
<CardContent className="flex items-center justify-between px-4 py-3">
|
<CardContent className="flex items-center justify-between px-4 py-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Skeleton className="h-4 w-48" />
|
<Skeleton className="h-4 w-48" />
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="space-y-5">
|
<aside className="space-y-5">
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Vandaag
|
Vandaag
|
||||||
|
|
@ -139,7 +139,7 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps)
|
||||||
|
|
||||||
<EnergyMeterCard meter={planningMeter} />
|
<EnergyMeterCard meter={planningMeter} />
|
||||||
|
|
||||||
<Card tone="primary" elevation="raised" className="py-0">
|
<Card tone="primary" elevation="raised" className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/75">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/75">
|
||||||
Bewuste grens
|
Bewuste grens
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { buildPathWithQuery } from "@/lib/auth/navigation";
|
import { buildPathWithQuery } from "@/lib/auth/navigation";
|
||||||
import {
|
import {
|
||||||
|
|
@ -11,12 +12,19 @@ import {
|
||||||
getOptionalTimeValue,
|
getOptionalTimeValue,
|
||||||
} from "@/lib/forms/parse";
|
} from "@/lib/forms/parse";
|
||||||
import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options";
|
import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options";
|
||||||
|
import { ProfileAvatarProcessingError } from "@/lib/profile/avatar-processing";
|
||||||
import {
|
import {
|
||||||
isAllowedProfileAvatarMimeType,
|
isAllowedProfileAvatarMimeType,
|
||||||
PROFILE_AVATAR_MAX_BYTES,
|
PROFILE_AVATAR_UPLOAD_MAX_BYTES,
|
||||||
} from "@/lib/profile/avatar";
|
} from "@/lib/profile/avatar";
|
||||||
import { saveSettingsForCurrentUser } from "@/lib/profile/service";
|
import {
|
||||||
import type { SettingsSubmission } from "@/lib/profile/types";
|
saveProfileAvatarForCurrentUser,
|
||||||
|
saveSettingsForCurrentUser,
|
||||||
|
} from "@/lib/profile/service";
|
||||||
|
import type {
|
||||||
|
AvatarUploadActionState,
|
||||||
|
SettingsSubmission,
|
||||||
|
} from "@/lib/profile/types";
|
||||||
|
|
||||||
const LOCALE_VALUES = ["nl-NL"] as const;
|
const LOCALE_VALUES = ["nl-NL"] as const;
|
||||||
const ONBOARDING_TIMEZONE_VALUES = ONBOARDING_TIMEZONE_OPTIONS.map((option) => option.value);
|
const ONBOARDING_TIMEZONE_VALUES = ONBOARDING_TIMEZONE_OPTIONS.map((option) => option.value);
|
||||||
|
|
@ -47,7 +55,7 @@ function getOptionalAvatarFile(formData: FormData) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
value.size > PROFILE_AVATAR_MAX_BYTES ||
|
value.size > PROFILE_AVATAR_UPLOAD_MAX_BYTES ||
|
||||||
!isAllowedProfileAvatarMimeType(value.type)
|
!isAllowedProfileAvatarMimeType(value.type)
|
||||||
) {
|
) {
|
||||||
throw new FormDataValidationError("invalid-avatar-file");
|
throw new FormDataValidationError("invalid-avatar-file");
|
||||||
|
|
@ -110,6 +118,44 @@ function buildSettingsSubmission(formData: FormData): SettingsSubmission {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadAvatarAction(
|
||||||
|
_previousState: AvatarUploadActionState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<AvatarUploadActionState> {
|
||||||
|
try {
|
||||||
|
const avatarFile = getOptionalAvatarFile(formData);
|
||||||
|
|
||||||
|
if (!avatarFile) {
|
||||||
|
throw new FormDataValidationError("invalid-avatar-file");
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveProfileAvatarForCurrentUser(avatarFile);
|
||||||
|
revalidatePath("/settings");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "success",
|
||||||
|
code: "avatar-saved",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FormDataValidationError) {
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
code: error.code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof ProfileAvatarProcessingError) {
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
code: "invalid-avatar-file",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveSettingsAction(
|
export async function saveSettingsAction(
|
||||||
_previousState: null,
|
_previousState: null,
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
|
|
@ -121,6 +167,10 @@ export async function saveSettingsAction(
|
||||||
redirect(buildPathWithQuery("/settings", { error: error.code }));
|
redirect(buildPathWithQuery("/settings", { error: error.code }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error instanceof ProfileAvatarProcessingError) {
|
||||||
|
redirect(buildPathWithQuery("/settings", { error: "invalid-avatar-file" }));
|
||||||
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps)
|
||||||
<SettingsForm profileBundle={profileBundle} />
|
<SettingsForm profileBundle={profileBundle} />
|
||||||
|
|
||||||
<aside className="space-y-5">
|
<aside className="space-y-5">
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Account
|
Account
|
||||||
|
|
@ -111,7 +111,7 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps)
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Huidige status
|
Huidige status
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export function AuthPanel({
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex items-center">
|
<section className="flex items-center">
|
||||||
<Card elevation="raised" className="w-full rounded-[var(--radius-4xl)] py-0 backdrop-blur">
|
<Card elevation="raised" className="w-full rounded-[var(--radius-4xl)] pb-0 backdrop-blur">
|
||||||
<CardContent className="p-6 sm:p-8">
|
<CardContent className="p-6 sm:p-8">
|
||||||
<div className="mb-6 flex items-center justify-between gap-3">
|
<div className="mb-6 flex items-center justify-between gap-3">
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export function CheckInCard({ todayCheckIn }: CheckInCardProps) {
|
||||||
: "Leg je energiestart en slaapkwaliteit van vandaag vast.";
|
: "Leg je energiestart en slaapkwaliteit van vandaag vast.";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Ochtendcheck-in
|
Ochtendcheck-in
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ export function CheckInForm({ todayCheckIn }: CheckInFormProps) {
|
||||||
<input type="hidden" name="energyScore" value={energyScore ?? ""} />
|
<input type="hidden" name="energyScore" value={energyScore ?? ""} />
|
||||||
<input type="hidden" name="sleepQuality" value={sleepQuality ?? ""} />
|
<input type="hidden" name="sleepQuality" value={sleepQuality ?? ""} />
|
||||||
|
|
||||||
<Card elevation="raised" className="py-0">
|
<Card elevation="raised" className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Ochtendcheck-in
|
Ochtendcheck-in
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export function AccountMenu({ authState }: AccountMenuProps) {
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger aria-label="Account menu">
|
<DropdownMenuTrigger aria-label="Account menu">
|
||||||
<CircleUserRoundIcon className="size-4" />
|
<CircleUserRoundIcon className="size-4" />
|
||||||
Account
|
<span className="hidden sm:inline">Account</span>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
{authState.isConfigured ? (
|
{authState.isConfigured ? (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { getAuthState } from "@/lib/auth/session";
|
import { getAuthState } from "@/lib/auth/session";
|
||||||
|
import { BottomNav } from "@/components/navigation/bottom-nav";
|
||||||
import { TopNav } from "@/components/navigation/top-nav";
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ export async function AppShell({
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-8">
|
<div className="mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-8">
|
||||||
<TopNav authState={authState} />
|
<TopNav authState={authState} />
|
||||||
<div className={cn("flex-1", contentClassName)}>{children}</div>
|
<div className={cn("flex-1", contentClassName)}>{children}</div>
|
||||||
|
<BottomNav />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
62
components/navigation/bottom-nav.tsx
Normal file
62
components/navigation/bottom-nav.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { bottomNavItems, isActivePath, shouldUseBottomNav } from "@/components/navigation/navigation-config";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function BottomNav() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
if (!shouldUseBottomNav(pathname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="h-24 sm:hidden" aria-hidden="true" />
|
||||||
|
<nav
|
||||||
|
aria-label="Mobiele navigatie"
|
||||||
|
className="fixed inset-x-0 bottom-0 z-40 px-4 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:hidden"
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex max-w-md items-end gap-2 rounded-[var(--radius-3xl)] border border-border/75 bg-card/94 px-3 py-2 shadow-[var(--shadow-3)] backdrop-blur">
|
||||||
|
{bottomNavItems.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(
|
||||||
|
"state-layer flex min-h-14 flex-1 flex-col items-center justify-center gap-1.5 rounded-[var(--radius-2xl)] px-2 py-2 text-center transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-primary-container text-primary-container-foreground shadow-[var(--shadow-1)]"
|
||||||
|
: "text-muted-foreground hover:bg-surface-container-high hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex size-8 items-center justify-center rounded-full transition-colors",
|
||||||
|
isActive ? "bg-primary text-primary-foreground" : "bg-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"type-label-medium leading-none",
|
||||||
|
isActive && "font-semibold text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
components/navigation/navigation-config.ts
Normal file
73
components/navigation/navigation-config.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
ClipboardCheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
LayoutDashboardIcon,
|
||||||
|
Settings2Icon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export const primaryNavItems = [
|
||||||
|
{
|
||||||
|
href: "/",
|
||||||
|
label: "About",
|
||||||
|
icon: InfoIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/dashboard",
|
||||||
|
label: "Dashboard",
|
||||||
|
icon: LayoutDashboardIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/planning",
|
||||||
|
label: "Planning",
|
||||||
|
icon: ActivityIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/check-in",
|
||||||
|
label: "Check-in",
|
||||||
|
icon: ClipboardCheckIcon,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const bottomNavItems = [
|
||||||
|
{
|
||||||
|
href: "/dashboard",
|
||||||
|
label: "Dashboard",
|
||||||
|
icon: LayoutDashboardIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/check-in",
|
||||||
|
label: "Check-in",
|
||||||
|
icon: ClipboardCheckIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/planning",
|
||||||
|
label: "Planning",
|
||||||
|
icon: ActivityIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/settings",
|
||||||
|
label: "Instellingen",
|
||||||
|
icon: Settings2Icon,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const bottomNavRoutePrefixes = ["/dashboard", "/check-in", "/planning", "/settings"];
|
||||||
|
|
||||||
|
export function isActivePath(pathname: string, href: string) {
|
||||||
|
if (href === "/") {
|
||||||
|
return pathname === "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathname === href || pathname.startsWith(`${href}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldUseBottomNav(pathname: string | null) {
|
||||||
|
if (!pathname) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bottomNavRoutePrefixes.some((href) => isActivePath(pathname, href));
|
||||||
|
}
|
||||||
|
|
@ -24,7 +24,7 @@ export function ThemeMenu() {
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger aria-label="Thema kiezen">
|
<DropdownMenuTrigger aria-label="Thema kiezen">
|
||||||
<MonitorCogIcon className="size-4" />
|
<MonitorCogIcon className="size-4" />
|
||||||
Theme
|
<span className="hidden sm:inline">Theme</span>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuLabel>Weergave</DropdownMenuLabel>
|
<DropdownMenuLabel>Weergave</DropdownMenuLabel>
|
||||||
|
|
|
||||||
|
|
@ -2,54 +2,23 @@
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import {
|
|
||||||
ActivityIcon,
|
|
||||||
ClipboardCheckIcon,
|
|
||||||
InfoIcon,
|
|
||||||
LayoutDashboardIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { AuthState } from "@/lib/auth/session";
|
import type { AuthState } from "@/lib/auth/session";
|
||||||
import { AccountMenu } from "@/components/navigation/account-menu";
|
import { AccountMenu } from "@/components/navigation/account-menu";
|
||||||
|
import {
|
||||||
|
isActivePath,
|
||||||
|
primaryNavItems,
|
||||||
|
shouldUseBottomNav,
|
||||||
|
} from "@/components/navigation/navigation-config";
|
||||||
import { ThemeMenu } from "@/components/navigation/theme-menu";
|
import { ThemeMenu } from "@/components/navigation/theme-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const primaryNavItems = [
|
|
||||||
{
|
|
||||||
href: "/",
|
|
||||||
label: "About",
|
|
||||||
icon: InfoIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/dashboard",
|
|
||||||
label: "Dashboard",
|
|
||||||
icon: LayoutDashboardIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/planning",
|
|
||||||
label: "Planning",
|
|
||||||
icon: ActivityIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/check-in",
|
|
||||||
label: "Check-in",
|
|
||||||
icon: ClipboardCheckIcon,
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type TopNavProps = {
|
type TopNavProps = {
|
||||||
authState: AuthState;
|
authState: AuthState;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isActivePath(pathname: string, href: string) {
|
|
||||||
if (href === "/") {
|
|
||||||
return pathname === "/";
|
|
||||||
}
|
|
||||||
|
|
||||||
return pathname === href || pathname.startsWith(`${href}/`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TopNav({ authState }: TopNavProps) {
|
export function TopNav({ authState }: TopNavProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const useCompactBottomNav = shouldUseBottomNav(pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-4 z-40">
|
<header className="sticky top-4 z-40">
|
||||||
|
|
@ -65,7 +34,10 @@ export function TopNav({ authState }: TopNavProps) {
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
aria-label="Hoofdnavigatie"
|
aria-label="Hoofdnavigatie"
|
||||||
className="flex flex-1 flex-wrap items-center gap-2 md:ml-6"
|
className={cn(
|
||||||
|
"flex flex-1 flex-wrap items-center gap-2 md:ml-6",
|
||||||
|
useCompactBottomNav ? "hidden sm:flex" : "flex",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{primaryNavItems.map((item) => {
|
{primaryNavItems.map((item) => {
|
||||||
const isActive = pathname ? isActivePath(pathname, item.href) : false;
|
const isActive = pathname ? isActivePath(pathname, item.href) : false;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
export function OnboardingStepIntro() {
|
export function OnboardingStepIntro() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card tone="subtle" className="py-0 shadow-none">
|
<Card tone="subtle" className="pb-0 shadow-none">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
|
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
|
||||||
Wat je hier wél krijgt
|
Wat je hier wél krijgt
|
||||||
|
|
@ -23,7 +23,7 @@ export function OnboardingStepIntro() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card tone="subtle" className="py-0 shadow-none">
|
<Card tone="subtle" className="pb-0 shadow-none">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
|
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
|
||||||
Wat deze app niet doet
|
Wat deze app niet doet
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export function OnboardingStepPreferences({
|
||||||
}: OnboardingStepPreferencesProps) {
|
}: OnboardingStepPreferencesProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card tone="subtle" className="py-0 shadow-none">
|
<Card tone="subtle" className="pb-0 shadow-none">
|
||||||
<CardContent className="flex items-start justify-between gap-4 py-5">
|
<CardContent className="flex items-start justify-between gap-4 py-5">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-sm font-semibold text-foreground">
|
<Label className="text-sm font-semibold text-foreground">
|
||||||
|
|
@ -39,7 +39,7 @@ export function OnboardingStepPreferences({
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card tone="subtle" className="py-0 shadow-none">
|
<Card tone="subtle" className="pb-0 shadow-none">
|
||||||
<CardContent className="space-y-4 py-5">
|
<CardContent className="space-y-4 py-5">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -82,7 +82,7 @@ export function OnboardingStepPreferences({
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card tone="subtle" className="py-0 shadow-none">
|
<Card tone="subtle" className="pb-0 shadow-none">
|
||||||
<CardContent className="flex items-start justify-between gap-4 py-5">
|
<CardContent className="flex items-start justify-between gap-4 py-5">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-sm font-semibold text-foreground">
|
<Label className="text-sm font-semibold text-foreground">
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ export function ActivityForm({
|
||||||
<input type="hidden" name="impactLevel" value={impactLevel} />
|
<input type="hidden" name="impactLevel" value={impactLevel} />
|
||||||
<input type="hidden" name="priorityLevel" value={priorityLevel} />
|
<input type="hidden" name="priorityLevel" value={priorityLevel} />
|
||||||
|
|
||||||
<Card elevation="raised" className="py-0">
|
<Card elevation="raised" className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Dagplanning
|
Dagplanning
|
||||||
|
|
@ -215,7 +215,7 @@ export function ActivityForm({
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<Card tone="subtle" className="py-0 shadow-none">
|
<Card tone="subtle" className="pb-0 shadow-none">
|
||||||
<CardContent className="space-y-2 py-5">
|
<CardContent className="space-y-2 py-5">
|
||||||
<p className="text-sm font-semibold text-foreground">Vooruitblik op de meter</p>
|
<p className="text-sm font-semibold text-foreground">Vooruitblik op de meter</p>
|
||||||
<p className="text-sm leading-7 text-muted-foreground" aria-live="polite">
|
<p className="text-sm leading-7 text-muted-foreground" aria-live="polite">
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ export function AdHocActivityForm({
|
||||||
<input type="hidden" name="categoryId" value={categoryId} />
|
<input type="hidden" name="categoryId" value={categoryId} />
|
||||||
<input type="hidden" name="impactLevel" value={impactLevel} />
|
<input type="hidden" name="impactLevel" value={impactLevel} />
|
||||||
|
|
||||||
<Card tone="subtle" className="py-0">
|
<Card tone="subtle" className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Ongepland
|
Ongepland
|
||||||
|
|
@ -252,7 +252,7 @@ export function AdHocActivityForm({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card tone="subtle" className="py-0 shadow-none">
|
<Card tone="subtle" className="pb-0 shadow-none">
|
||||||
<CardContent className="space-y-2 py-5">
|
<CardContent className="space-y-2 py-5">
|
||||||
<p className="text-sm font-semibold text-foreground">Effect op je dagtotaal</p>
|
<p className="text-sm font-semibold text-foreground">Effect op je dagtotaal</p>
|
||||||
<p className="text-sm leading-7 text-muted-foreground" aria-live="polite">
|
<p className="text-sm leading-7 text-muted-foreground" aria-live="polite">
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ function getStatusAccentClassName(key: "planned" | "completed" | "adjusted" | "s
|
||||||
|
|
||||||
export function DayOverviewCard({ overview }: DayOverviewCardProps) {
|
export function DayOverviewCard({ overview }: DayOverviewCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Dagoverzicht
|
Dagoverzicht
|
||||||
|
|
@ -62,7 +62,7 @@ export function DayOverviewCard({ overview }: DayOverviewCardProps) {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5 pb-6">
|
<CardContent className="space-y-5 pb-6">
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<Card tone="subtle" size="sm" className="py-0">
|
<Card tone="subtle" size="sm" className="pb-0">
|
||||||
<CardContent className="space-y-1 py-4">
|
<CardContent className="space-y-1 py-4">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
Vooraf gepland
|
Vooraf gepland
|
||||||
|
|
@ -76,7 +76,7 @@ export function DayOverviewCard({ overview }: DayOverviewCardProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card tone="subtle" size="sm" className="py-0">
|
<Card tone="subtle" size="sm" className="pb-0">
|
||||||
<CardContent className="space-y-1 py-4">
|
<CardContent className="space-y-1 py-4">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
Werkelijk gedaan
|
Werkelijk gedaan
|
||||||
|
|
@ -90,7 +90,7 @@ export function DayOverviewCard({ overview }: DayOverviewCardProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card tone="subtle" size="sm" className="py-0">
|
<Card tone="subtle" size="sm" className="pb-0">
|
||||||
<CardContent className="space-y-1 py-4">
|
<CardContent className="space-y-1 py-4">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
Ongepland erbij
|
Ongepland erbij
|
||||||
|
|
@ -104,7 +104,7 @@ export function DayOverviewCard({ overview }: DayOverviewCardProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card tone="subtle" size="sm" className="py-0">
|
<Card tone="subtle" size="sm" className="pb-0">
|
||||||
<CardContent className="space-y-1 py-4">
|
<CardContent className="space-y-1 py-4">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
Nog open
|
Nog open
|
||||||
|
|
@ -151,7 +151,7 @@ export function DayOverviewCard({ overview }: DayOverviewCardProps) {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
<span className="rounded-full bg-black/12 px-1.5 py-0.5 text-[0.7rem] font-semibold text-current">
|
<span className="rounded-full bg-black/12 px-1.5 pb-0.5 text-[0.7rem] font-semibold text-current">
|
||||||
{item.value}
|
{item.value}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ export function EnergyMeterCard({
|
||||||
meter.dailyBudget === null ? null : Math.min(100, Math.max(0, meter.progressPercent ?? 0));
|
meter.dailyBudget === null ? null : Math.min(100, Math.max(0, meter.progressPercent ?? 0));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card tone={tone === "default" ? "default" : "subtle"} className="py-0">
|
<Card tone={tone === "default" ? "default" : "subtle"} className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
EnergyMeter
|
EnergyMeter
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ export function TodayActivitiesList({
|
||||||
skipReasons,
|
skipReasons,
|
||||||
}: TodayActivitiesListProps) {
|
}: TodayActivitiesListProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Vandaag in beeld
|
Vandaag in beeld
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useState } from "react";
|
import type { ChangeEvent } from "react";
|
||||||
import { saveSettingsAction } from "@/app/settings/actions";
|
import { useActionState, useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
saveSettingsAction,
|
||||||
|
uploadAvatarAction,
|
||||||
|
} from "@/app/settings/actions";
|
||||||
|
import { showStatusToast } from "@/lib/feedback/toast";
|
||||||
|
import { getSettingsStatusToast } from "@/lib/feedback/status-messages";
|
||||||
import { PreferenceHiddenFields } from "@/components/preferences/preference-hidden-fields";
|
import { PreferenceHiddenFields } from "@/components/preferences/preference-hidden-fields";
|
||||||
import { ProfileAvatar } from "@/components/profile/profile-avatar";
|
import { ProfileAvatar } from "@/components/profile/profile-avatar";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
|
@ -26,9 +33,15 @@ import { Separator } from "@/components/ui/separator";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options";
|
import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options";
|
||||||
import { PROFILE_AVATAR_MAX_BYTES } from "@/lib/profile/avatar";
|
import {
|
||||||
|
PROFILE_AVATAR_MAX_DIMENSION,
|
||||||
|
PROFILE_AVATAR_UPLOAD_MAX_BYTES,
|
||||||
|
} from "@/lib/profile/avatar";
|
||||||
import { usePreferenceDraft } from "@/lib/preferences/use-preferences-draft";
|
import { usePreferenceDraft } from "@/lib/preferences/use-preferences-draft";
|
||||||
import type { ProfileBundle } from "@/lib/profile/types";
|
import type {
|
||||||
|
AvatarUploadActionState,
|
||||||
|
ProfileBundle,
|
||||||
|
} from "@/lib/profile/types";
|
||||||
|
|
||||||
type SettingsFormProps = {
|
type SettingsFormProps = {
|
||||||
profileBundle: ProfileBundle;
|
profileBundle: ProfileBundle;
|
||||||
|
|
@ -41,29 +54,192 @@ const LOCALE_OPTIONS = [
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const INITIAL_AVATAR_UPLOAD_STATE: AvatarUploadActionState = {
|
||||||
|
status: "idle",
|
||||||
|
};
|
||||||
|
|
||||||
export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
const [, formAction, isPending] = useActionState(saveSettingsAction, null);
|
const [, formAction, isPending] = useActionState(saveSettingsAction, null);
|
||||||
|
const [avatarState, avatarFormAction, isAvatarPending] = useActionState(
|
||||||
|
uploadAvatarAction,
|
||||||
|
INITIAL_AVATAR_UPLOAD_STATE,
|
||||||
|
);
|
||||||
const [locale, setLocale] = useState(profileBundle.profile.locale);
|
const [locale, setLocale] = useState(profileBundle.profile.locale);
|
||||||
|
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [hasPendingAvatar, setHasPendingAvatar] = useState(false);
|
||||||
|
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
const { draft, updateDraft } = usePreferenceDraft(profileBundle);
|
const { draft, updateDraft } = usePreferenceDraft(profileBundle);
|
||||||
const avatarLimitInMb = PROFILE_AVATAR_MAX_BYTES / (1024 * 1024);
|
const avatarLimitInMb = PROFILE_AVATAR_UPLOAD_MAX_BYTES / (1024 * 1024);
|
||||||
const profileTitle =
|
const profileTitle =
|
||||||
profileBundle.profile.displayName ??
|
profileBundle.profile.displayName ??
|
||||||
profileBundle.profile.email ??
|
profileBundle.profile.email ??
|
||||||
"Ingelogde gebruiker";
|
"Ingelogde gebruiker";
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<form
|
return () => {
|
||||||
action={formAction}
|
if (avatarPreviewUrl) {
|
||||||
className="space-y-6"
|
URL.revokeObjectURL(avatarPreviewUrl);
|
||||||
aria-busy={isPending}
|
}
|
||||||
>
|
};
|
||||||
<input type="hidden" name="locale" value={locale} />
|
}, [avatarPreviewUrl]);
|
||||||
<PreferenceHiddenFields draft={draft} />
|
|
||||||
|
|
||||||
<Card elevation="raised" className="py-0">
|
useEffect(() => {
|
||||||
<CardHeader className="pb-0">
|
if (avatarState.status === "idle" || !avatarState.code) {
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
return;
|
||||||
Account
|
}
|
||||||
|
|
||||||
|
const toast = getSettingsStatusToast(
|
||||||
|
avatarState.status === "error" ? avatarState.code : null,
|
||||||
|
avatarState.status === "success" ? avatarState.code : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (toast) {
|
||||||
|
showStatusToast(toast);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avatarState.status === "success") {
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}, [avatarState, router]);
|
||||||
|
|
||||||
|
function handleAvatarChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = event.currentTarget.files?.[0] ?? null;
|
||||||
|
|
||||||
|
setAvatarPreviewUrl((currentValue) => {
|
||||||
|
if (currentValue) {
|
||||||
|
URL.revokeObjectURL(currentValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return file ? URL.createObjectURL(file) : null;
|
||||||
|
});
|
||||||
|
setHasPendingAvatar(Boolean(file));
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
event.currentTarget.form?.requestSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAvatarSelection() {
|
||||||
|
if (avatarInputRef.current) {
|
||||||
|
avatarInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
setAvatarPreviewUrl((currentValue) => {
|
||||||
|
if (currentValue) {
|
||||||
|
URL.revokeObjectURL(currentValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
setHasPendingAvatar(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<form action={avatarFormAction} className="space-y-6" aria-busy={isAvatarPending}>
|
||||||
|
<Card className="pb-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Profielfoto
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-2xl text-foreground">
|
||||||
|
Werk je foto direct bij
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="max-w-2xl text-sm leading-7 text-muted-foreground">
|
||||||
|
Kies een afbeelding en sla die meteen apart op. Grote foto's worden
|
||||||
|
server-side verkleind voordat ze in storage terechtkomen.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-6 pb-6 lg:grid-cols-[16rem_1fr]">
|
||||||
|
<Card tone="subtle" className="pb-0 shadow-none">
|
||||||
|
<CardContent className="flex h-full flex-col items-center gap-4 px-5 py-5 text-center">
|
||||||
|
<ProfileAvatar
|
||||||
|
avatarUrl={avatarPreviewUrl ?? profileBundle.profile.avatarUrl}
|
||||||
|
displayName={profileBundle.profile.displayName}
|
||||||
|
email={profileBundle.profile.email}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold text-foreground">{profileTitle}</p>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
{profileBundle.profile.tagline ?? "Nog geen 1-regelige introductie toegevoegd."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
id="avatar"
|
||||||
|
ref={avatarInputRef}
|
||||||
|
name="avatar"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
disabled={isPending || isAvatarPending}
|
||||||
|
className="sr-only"
|
||||||
|
onChange={handleAvatarChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isPending || isAvatarPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (avatarInputRef.current) {
|
||||||
|
avatarInputRef.current.value = "";
|
||||||
|
avatarInputRef.current.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isAvatarPending ? "Foto uploaden..." : "Kies nieuwe foto"}
|
||||||
|
</Button>
|
||||||
|
{avatarPreviewUrl ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={isPending || isAvatarPending}
|
||||||
|
onClick={clearAvatarSelection}
|
||||||
|
>
|
||||||
|
Wis selectie
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs leading-6 text-muted-foreground">
|
||||||
|
JPG, PNG of WebP tot {avatarLimitInMb} MB als bronbestand. Voor opslag
|
||||||
|
wordt de foto automatisch teruggebracht naar maximaal{" "}
|
||||||
|
{PROFILE_AVATAR_MAX_DIMENSION} × {PROFILE_AVATAR_MAX_DIMENSION} px.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs leading-6 text-muted-foreground" aria-live="polite">
|
||||||
|
{isAvatarPending
|
||||||
|
? "Nieuwe profielfoto wordt verwerkt en opgeslagen..."
|
||||||
|
: avatarState.status === "success"
|
||||||
|
? "Nieuwe profielfoto is opgeslagen en wordt nu overal ververst."
|
||||||
|
: avatarState.status === "error" && avatarPreviewUrl
|
||||||
|
? "Deze foto kon niet worden opgeslagen. Kies een andere afbeelding of probeer het opnieuw."
|
||||||
|
: hasPendingAvatar
|
||||||
|
? "Nieuwe profielfoto staat klaar om direct te uploaden."
|
||||||
|
: "Je huidige foto blijft actief totdat je een nieuwe versie kiest."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
className="space-y-6"
|
||||||
|
aria-busy={isPending}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="locale" value={locale} />
|
||||||
|
<PreferenceHiddenFields draft={draft} />
|
||||||
|
|
||||||
|
<Card elevation="raised" className="pb-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Account
|
||||||
</p>
|
</p>
|
||||||
<CardTitle className="font-[family-name:var(--font-display)] text-3xl text-foreground">
|
<CardTitle className="font-[family-name:var(--font-display)] text-3xl text-foreground">
|
||||||
Basisinstellingen voor jouw account
|
Basisinstellingen voor jouw account
|
||||||
|
|
@ -83,10 +259,10 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Profiel
|
Profiel
|
||||||
</p>
|
</p>
|
||||||
<CardTitle className="text-2xl text-foreground">
|
<CardTitle className="text-2xl text-foreground">
|
||||||
Laat in een paar regels zien wie je bent
|
Laat in een paar regels zien wie je bent
|
||||||
|
|
@ -96,43 +272,8 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
Dit helpt straks ook bij demo-accounts en voorbeeldgebruik.
|
Dit helpt straks ook bij demo-accounts en voorbeeldgebruik.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-6 pb-6 lg:grid-cols-[16rem_1fr]">
|
<CardContent className="pb-6">
|
||||||
<Card tone="subtle" className="py-0 shadow-none">
|
<div className="grid gap-5">
|
||||||
<CardContent className="flex h-full flex-col items-center gap-4 px-5 py-5 text-center">
|
|
||||||
<ProfileAvatar
|
|
||||||
avatarUrl={profileBundle.profile.avatarUrl}
|
|
||||||
displayName={profileBundle.profile.displayName}
|
|
||||||
email={profileBundle.profile.email}
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="font-semibold text-foreground">{profileTitle}</p>
|
|
||||||
<p className="text-sm leading-7 text-muted-foreground">
|
|
||||||
{profileBundle.profile.tagline ?? "Nog geen 1-regelige introductie toegevoegd."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full space-y-2 text-left">
|
|
||||||
<Label htmlFor="avatar" className="text-foreground">
|
|
||||||
Profielfoto
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="avatar"
|
|
||||||
name="avatar"
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/webp"
|
|
||||||
disabled={isPending}
|
|
||||||
className="h-auto rounded-[1.25rem] bg-background/80 px-4 py-3 file:mr-3 file:rounded-full file:bg-secondary file:px-3 file:py-1.5 file:text-secondary-foreground"
|
|
||||||
/>
|
|
||||||
<p className="text-xs leading-6 text-muted-foreground">
|
|
||||||
JPG, PNG of WebP tot {avatarLimitInMb} MB. Een nieuw bestand vervangt je
|
|
||||||
huidige foto.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-5">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="display-name" className="text-foreground">
|
<Label htmlFor="display-name" className="text-foreground">
|
||||||
Weergavenaam
|
Weergavenaam
|
||||||
|
|
@ -178,11 +319,11 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<section className="grid gap-5 lg:grid-cols-2">
|
<section className="grid gap-5 lg:grid-cols-2">
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Taal en tijd
|
Taal en tijd
|
||||||
|
|
@ -235,14 +376,14 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Interface
|
Interface
|
||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 pb-6">
|
<CardContent className="space-y-4 pb-6">
|
||||||
<Card tone="subtle" className="py-0 shadow-none">
|
<Card tone="subtle" className="pb-0 shadow-none">
|
||||||
<CardContent className="flex items-start justify-between gap-4 py-5">
|
<CardContent className="flex items-start justify-between gap-4 py-5">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="show-energy-points" className="text-sm font-semibold text-foreground">
|
<Label htmlFor="show-energy-points" className="text-sm font-semibold text-foreground">
|
||||||
|
|
@ -267,14 +408,14 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-5 lg:grid-cols-2">
|
<section className="grid gap-5 lg:grid-cols-2">
|
||||||
<Card className="py-0">
|
<Card className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Reminders
|
Reminders
|
||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 pb-6">
|
<CardContent className="space-y-4 pb-6">
|
||||||
<Card tone="subtle" className="py-0 shadow-none">
|
<Card tone="subtle" className="pb-0 shadow-none">
|
||||||
<CardContent className="space-y-4 py-5">
|
<CardContent className="space-y-4 py-5">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -318,7 +459,7 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card tone="subtle" className="py-0 shadow-none">
|
<Card tone="subtle" className="pb-0 shadow-none">
|
||||||
<CardContent className="flex items-start justify-between gap-4 py-5">
|
<CardContent className="flex items-start justify-between gap-4 py-5">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="reflection-reminder-enabled" className="text-sm font-semibold text-foreground">
|
<Label htmlFor="reflection-reminder-enabled" className="text-sm font-semibold text-foreground">
|
||||||
|
|
@ -341,7 +482,7 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card tone="primary" elevation="raised" className="py-0">
|
<Card tone="primary" elevation="raised" className="pb-0">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/75">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/75">
|
||||||
Bewuste grenzen
|
Bewuste grenzen
|
||||||
|
|
@ -368,6 +509,7 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
||||||
{isPending ? "Instellingen opslaan..." : "Instellingen opslaan"}
|
{isPending ? "Instellingen opslaan..." : "Instellingen opslaan"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ const alertVariants = cva(
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "border-border/70 bg-card/92 text-card-foreground",
|
default: "border-outline-variant/70 bg-surface-container-high text-card-foreground",
|
||||||
info: "border-primary/15 bg-secondary text-foreground",
|
info: "border-primary/16 bg-primary-container text-primary-container-foreground",
|
||||||
success: "border-success/30 bg-success/14 text-foreground",
|
success: "border-success/30 bg-success/14 text-foreground",
|
||||||
warning: "border-warning/32 bg-warning/16 text-foreground",
|
warning: "border-warning/32 bg-warning/16 text-foreground",
|
||||||
destructive:
|
destructive:
|
||||||
|
|
@ -42,7 +42,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
<div
|
<div
|
||||||
data-slot="alert-title"
|
data-slot="alert-title"
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-medium leading-6 group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
"type-title-medium font-medium leading-6 group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -58,7 +58,7 @@ function AlertDescription({
|
||||||
<div
|
<div
|
||||||
data-slot="alert-description"
|
data-slot="alert-description"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm text-balance leading-7 text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
"type-body-medium text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,16 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"group/button inline-flex shrink-0 items-center justify-center rounded-[var(--radius-full,9999px)] border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-150 ease-[cubic-bezier(.2,.7,.2,1)] outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-[1.125rem]",
|
"state-layer group/button inline-flex shrink-0 items-center justify-center rounded-[var(--radius-full,9999px)] border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-150 ease-[cubic-bezier(.2,.7,.2,1)] outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-[1.125rem]",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary !text-white shadow-[var(--shadow-1)] hover:bg-primary/90 hover:shadow-[var(--shadow-2)] [a]:hover:bg-primary/90 [&_svg]:!text-white",
|
"bg-primary !text-white shadow-[var(--shadow-1)] hover:bg-primary/90 hover:shadow-[var(--shadow-2)] [a]:hover:bg-primary/90 [&_svg]:!text-white",
|
||||||
outline:
|
outline:
|
||||||
"border-border bg-background/88 hover:bg-muted hover:text-foreground hover:shadow-[var(--shadow-1)] aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
"border-outline-variant bg-surface-container-low/92 hover:bg-surface-container hover:text-foreground hover:shadow-[var(--shadow-1)] aria-expanded:bg-surface-container aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-[var(--shadow-1)] hover:bg-secondary/85 hover:shadow-[var(--shadow-2)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
"bg-secondary-container text-secondary-container-foreground shadow-[var(--shadow-1)] hover:bg-secondary-container/88 hover:shadow-[var(--shadow-2)] aria-expanded:bg-secondary-container aria-expanded:text-secondary-container-foreground",
|
||||||
success:
|
success:
|
||||||
"bg-success !text-white shadow-[var(--shadow-1)] hover:brightness-[0.98] hover:shadow-[var(--shadow-2)] [&_svg]:!text-white",
|
"bg-success !text-white shadow-[var(--shadow-1)] hover:brightness-[0.98] hover:shadow-[var(--shadow-2)] [&_svg]:!text-white",
|
||||||
warning:
|
warning:
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const cardVariants = cva(
|
const cardVariants = cva(
|
||||||
"group/card flex flex-col gap-4 overflow-hidden rounded-[var(--radius-xl)] py-4 text-sm text-card-foreground ring-1 ring-border/75 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-[var(--radius-xl)] *:[img:last-child]:rounded-b-[var(--radius-xl)]",
|
"group/card flex flex-col gap-4 overflow-hidden rounded-[var(--radius-xl)] py-4 text-sm text-card-foreground ring-1 ring-outline-variant/70 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-[var(--radius-xl)] *:[img:last-child]:rounded-b-[var(--radius-xl)]",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
tone: {
|
tone: {
|
||||||
default: "bg-card/92",
|
default: "bg-surface-container text-card-foreground",
|
||||||
subtle: "bg-background/78",
|
subtle: "bg-surface-container-low text-card-foreground",
|
||||||
primary: "bg-primary text-primary-foreground ring-primary/10",
|
primary: "bg-primary text-primary-foreground ring-primary/10",
|
||||||
},
|
},
|
||||||
elevation: {
|
elevation: {
|
||||||
|
|
@ -64,7 +64,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
<div
|
<div
|
||||||
data-slot="card-title"
|
data-slot="card-title"
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-heading text-base leading-snug font-semibold tracking-[-0.02em] group-data-[size=sm]/card:text-sm",
|
"type-title-large font-heading group-data-[size=sm]/card:text-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -76,7 +76,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-description"
|
data-slot="card-description"
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("type-body-medium text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -110,7 +110,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
<div
|
<div
|
||||||
data-slot="card-footer"
|
data-slot="card-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center rounded-b-[var(--radius-xl)] border-t border-border/65 bg-muted/60 p-4 group-data-[size=sm]/card:p-3",
|
"flex items-center rounded-b-[var(--radius-xl)] border-t border-outline-variant/60 bg-surface-container-high p-4 group-data-[size=sm]/card:p-3",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ function DropdownMenuTrigger({
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronDownIcon className="size-4 text-muted-foreground" />
|
<ChevronDownIcon className="hidden size-4 text-muted-foreground sm:inline-block" />
|
||||||
</Menu.Trigger>
|
</Menu.Trigger>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ export function TestWizardFlow() {
|
||||||
backAction={backAction}
|
backAction={backAction}
|
||||||
nextAction={nextAction}
|
nextAction={nextAction}
|
||||||
>
|
>
|
||||||
<Card tone="subtle" className="py-0 shadow-none">
|
<Card tone="subtle" className="pb-0 shadow-none">
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
|
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
|
||||||
{wizard.currentStep.title}
|
{wizard.currentStep.title}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,11 @@ const settingsStatusToasts: Record<string, StatusToast> = {
|
||||||
title: "Instellingen opgeslagen",
|
title: "Instellingen opgeslagen",
|
||||||
message: "Je voorkeuren zijn bijgewerkt.",
|
message: "Je voorkeuren zijn bijgewerkt.",
|
||||||
},
|
},
|
||||||
|
"avatar-saved": {
|
||||||
|
variant: "success",
|
||||||
|
title: "Profielfoto opgeslagen",
|
||||||
|
message: "Je profiel gebruikt nu direct de nieuwe, verkleinde afbeelding.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const settingsErrorToasts: Record<string, StatusToast> = {
|
const settingsErrorToasts: Record<string, StatusToast> = {
|
||||||
|
|
@ -48,7 +53,8 @@ const settingsErrorToasts: Record<string, StatusToast> = {
|
||||||
"invalid-avatar-file": {
|
"invalid-avatar-file": {
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: "Profielfoto niet opgeslagen",
|
title: "Profielfoto niet opgeslagen",
|
||||||
message: "Gebruik een JPG, PNG of WebP-bestand tot 2 MB en probeer het opnieuw.",
|
message:
|
||||||
|
"Gebruik een JPG, PNG of WebP-bestand. Grote foto's worden automatisch verkleind voordat ze worden opgeslagen.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
47
lib/profile/avatar-processing.ts
Normal file
47
lib/profile/avatar-processing.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import sharp from "sharp";
|
||||||
|
import {
|
||||||
|
PROFILE_AVATAR_MAX_DIMENSION,
|
||||||
|
PROFILE_AVATAR_STORED_MAX_BYTES,
|
||||||
|
} from "@/lib/profile/avatar";
|
||||||
|
|
||||||
|
const OUTPUT_QUALITY_STEPS = [82, 72, 64] as const;
|
||||||
|
const OUTPUT_CONTENT_TYPE = "image/webp";
|
||||||
|
|
||||||
|
export type ProcessedProfileAvatar = {
|
||||||
|
buffer: Buffer;
|
||||||
|
contentType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ProfileAvatarProcessingError extends Error {}
|
||||||
|
|
||||||
|
export async function processProfileAvatar(
|
||||||
|
file: File,
|
||||||
|
): Promise<ProcessedProfileAvatar> {
|
||||||
|
const inputBuffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
for (const quality of OUTPUT_QUALITY_STEPS) {
|
||||||
|
const outputBuffer = await sharp(inputBuffer)
|
||||||
|
.rotate()
|
||||||
|
.resize({
|
||||||
|
width: PROFILE_AVATAR_MAX_DIMENSION,
|
||||||
|
height: PROFILE_AVATAR_MAX_DIMENSION,
|
||||||
|
fit: "inside",
|
||||||
|
withoutEnlargement: true,
|
||||||
|
})
|
||||||
|
.webp({ quality })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
if (outputBuffer.byteLength <= PROFILE_AVATAR_STORED_MAX_BYTES) {
|
||||||
|
return {
|
||||||
|
buffer: outputBuffer,
|
||||||
|
contentType: OUTPUT_CONTENT_TYPE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ProfileAvatarProcessingError(
|
||||||
|
"De profielfoto blijft te groot na verkleinen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,9 @@ const PROFILE_AVATAR_ALLOWED_MIME_TYPES = [
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const PROFILE_AVATAR_BUCKET = "profile-avatars";
|
export const PROFILE_AVATAR_BUCKET = "profile-avatars";
|
||||||
export const PROFILE_AVATAR_MAX_BYTES = 2 * 1024 * 1024;
|
export const PROFILE_AVATAR_UPLOAD_MAX_BYTES = 12 * 1024 * 1024;
|
||||||
|
export const PROFILE_AVATAR_STORED_MAX_BYTES = 2 * 1024 * 1024;
|
||||||
|
export const PROFILE_AVATAR_MAX_DIMENSION = 700;
|
||||||
|
|
||||||
export function getProfileAvatarPath(userId: string) {
|
export function getProfileAvatarPath(userId: string) {
|
||||||
return `${userId}/avatar`;
|
return `${userId}/avatar`;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { getAuthenticatedUser } from "@/lib/auth/session";
|
import { getAuthenticatedUser } from "@/lib/auth/session";
|
||||||
import { isSupportedOnboardingTimezone } from "@/lib/onboarding/options";
|
import { isSupportedOnboardingTimezone } from "@/lib/onboarding/options";
|
||||||
|
import { processProfileAvatar } from "@/lib/profile/avatar-processing";
|
||||||
import {
|
import {
|
||||||
getProfileAvatarPath,
|
getProfileAvatarPath,
|
||||||
PROFILE_AVATAR_BUCKET,
|
PROFILE_AVATAR_BUCKET,
|
||||||
} from "@/lib/profile/avatar";
|
} from "@/lib/profile/avatar";
|
||||||
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import type {
|
import type {
|
||||||
OnboardingSubmission,
|
OnboardingSubmission,
|
||||||
|
|
@ -71,7 +73,6 @@ const DEFAULT_TIMEZONE = "Europe/Amsterdam";
|
||||||
const SUPPORTED_LOCALES = new Set([DEFAULT_LOCALE]);
|
const SUPPORTED_LOCALES = new Set([DEFAULT_LOCALE]);
|
||||||
|
|
||||||
async function buildProfileRecord(
|
async function buildProfileRecord(
|
||||||
supabase: SupabaseServerClient,
|
|
||||||
row: ProfileRow,
|
row: ProfileRow,
|
||||||
): Promise<ProfileRecord> {
|
): Promise<ProfileRecord> {
|
||||||
return {
|
return {
|
||||||
|
|
@ -81,7 +82,7 @@ async function buildProfileRecord(
|
||||||
tagline: row.tagline,
|
tagline: row.tagline,
|
||||||
bio: row.bio,
|
bio: row.bio,
|
||||||
avatarPath: row.avatar_path,
|
avatarPath: row.avatar_path,
|
||||||
avatarUrl: await getProfileAvatarUrl(supabase, row.avatar_path),
|
avatarUrl: await getProfileAvatarUrl(row.avatar_path),
|
||||||
locale: row.locale,
|
locale: row.locale,
|
||||||
timezone: row.timezone,
|
timezone: row.timezone,
|
||||||
onboardingSeen: row.onboarding_seen,
|
onboardingSeen: row.onboarding_seen,
|
||||||
|
|
@ -164,14 +165,14 @@ function resolveTimezone(value: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getProfileAvatarUrl(
|
async function getProfileAvatarUrl(
|
||||||
supabase: SupabaseServerClient,
|
|
||||||
avatarPath: string | null,
|
avatarPath: string | null,
|
||||||
) {
|
) {
|
||||||
if (!avatarPath) {
|
if (!avatarPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase.storage
|
const adminSupabase = createAdminClient();
|
||||||
|
const { data, error } = await adminSupabase.storage
|
||||||
.from(PROFILE_AVATAR_BUCKET)
|
.from(PROFILE_AVATAR_BUCKET)
|
||||||
.createSignedUrl(avatarPath, 60 * 60);
|
.createSignedUrl(avatarPath, 60 * 60);
|
||||||
|
|
||||||
|
|
@ -182,19 +183,39 @@ async function getProfileAvatarUrl(
|
||||||
return data.signedUrl;
|
return data.signedUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getRequiredAuthenticatedUser(supabase: SupabaseServerClient) {
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
error,
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Gebruiker kon niet worden geladen: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Er is geen ingelogde gebruiker beschikbaar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadProfileAvatar(
|
async function uploadProfileAvatar(
|
||||||
supabase: SupabaseServerClient,
|
|
||||||
userId: string,
|
userId: string,
|
||||||
file: File,
|
file: File,
|
||||||
) {
|
) {
|
||||||
const avatarPath = getProfileAvatarPath(userId);
|
const avatarPath = getProfileAvatarPath(userId);
|
||||||
const fileBytes = await file.arrayBuffer();
|
const processedAvatar = await processProfileAvatar(file);
|
||||||
|
const adminSupabase = createAdminClient();
|
||||||
|
|
||||||
const { error } = await supabase.storage
|
const { error } = await adminSupabase.storage
|
||||||
.from(PROFILE_AVATAR_BUCKET)
|
.from(PROFILE_AVATAR_BUCKET)
|
||||||
.upload(avatarPath, fileBytes, {
|
.upload(avatarPath, processedAvatar.buffer, {
|
||||||
cacheControl: "3600",
|
cacheControl: "3600",
|
||||||
contentType: file.type,
|
contentType: processedAvatar.contentType,
|
||||||
upsert: true,
|
upsert: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -366,15 +387,9 @@ export async function completeOnboardingForCurrentUser(
|
||||||
export async function saveSettingsForCurrentUser(
|
export async function saveSettingsForCurrentUser(
|
||||||
submission: SettingsSubmission,
|
submission: SettingsSubmission,
|
||||||
) {
|
) {
|
||||||
const user = await getAuthenticatedUser();
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new Error("Er is geen ingelogde gebruiker beschikbaar.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureProfileBundleForCurrentUser();
|
|
||||||
|
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
|
const user = await getRequiredAuthenticatedUser(supabase);
|
||||||
|
await ensureProfileBundleForCurrentUser();
|
||||||
const locale = normalizeLocale(submission.locale);
|
const locale = normalizeLocale(submission.locale);
|
||||||
const timezone = resolveTimezone(submission.timezone);
|
const timezone = resolveTimezone(submission.timezone);
|
||||||
const displayName = normalizeDisplayName(submission.displayName);
|
const displayName = normalizeDisplayName(submission.displayName);
|
||||||
|
|
@ -385,7 +400,7 @@ export async function saveSettingsForCurrentUser(
|
||||||
submission.morningReminderEnabled,
|
submission.morningReminderEnabled,
|
||||||
);
|
);
|
||||||
const avatarPath = submission.avatarFile
|
const avatarPath = submission.avatarFile
|
||||||
? await uploadProfileAvatar(supabase, user.id, submission.avatarFile)
|
? await uploadProfileAvatar(user.id, submission.avatarFile)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const { error: profileError } = await supabase
|
const { error: profileError } = await supabase
|
||||||
|
|
@ -421,6 +436,26 @@ export async function saveSettingsForCurrentUser(
|
||||||
return ensureProfileBundleForCurrentUser();
|
return ensureProfileBundleForCurrentUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveProfileAvatarForCurrentUser(file: File) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const user = await getRequiredAuthenticatedUser(supabase);
|
||||||
|
await ensureProfileBundleForCurrentUser();
|
||||||
|
const avatarPath = await uploadProfileAvatar(user.id, file);
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({
|
||||||
|
avatar_path: avatarPath,
|
||||||
|
})
|
||||||
|
.eq("id", user.id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Profielfoto kon niet worden opgeslagen: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ensureProfileBundleForCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureProfileBundleForCurrentUser(): Promise<ProfileBundle | null> {
|
export async function ensureProfileBundleForCurrentUser(): Promise<ProfileBundle | null> {
|
||||||
const user = await getAuthenticatedUser();
|
const user = await getAuthenticatedUser();
|
||||||
|
|
||||||
|
|
@ -457,7 +492,7 @@ export async function ensureProfileBundleForCurrentUser(): Promise<ProfileBundle
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
profile: await buildProfileRecord(supabase, profileRow),
|
profile: await buildProfileRecord(profileRow),
|
||||||
settings: mapUserSettingsRow(userSettingsRow),
|
settings: mapUserSettingsRow(userSettingsRow),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,3 +50,8 @@ export type SettingsSubmission = {
|
||||||
reflectionReminderEnabled: boolean;
|
reflectionReminderEnabled: boolean;
|
||||||
showEnergyPoints: boolean;
|
showEnergyPoints: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AvatarUploadActionState = {
|
||||||
|
status: "idle" | "success" | "error";
|
||||||
|
code?: string;
|
||||||
|
};
|
||||||
|
|
|
||||||
15
lib/supabase/admin.ts
Normal file
15
lib/supabase/admin.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
import { getSupabaseAdminEnv } from "@/lib/supabase/config";
|
||||||
|
|
||||||
|
export function createAdminClient() {
|
||||||
|
const { url, secretKey } = getSupabaseAdminEnv();
|
||||||
|
|
||||||
|
return createClient(url, secretKey, {
|
||||||
|
auth: {
|
||||||
|
persistSession: false,
|
||||||
|
autoRefreshToken: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -20,3 +20,22 @@ export function getSupabaseEnv() {
|
||||||
publishableKey,
|
publishableKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSupabaseAdminEnv() {
|
||||||
|
const { url } = getSupabaseEnv();
|
||||||
|
const secretKey =
|
||||||
|
process.env.SUPABASE_SECRET_KEY ??
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY ??
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_KEY;
|
||||||
|
|
||||||
|
if (!secretKey) {
|
||||||
|
throw new Error(
|
||||||
|
"Supabase admin-configuratie ontbreekt. Voeg SUPABASE_SECRET_KEY toe voor server-only taken zoals avatar-uploads.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
secretKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@ import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: "12mb",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -19,6 +19,7 @@
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"shadcn": "^4.3.0",
|
"shadcn": "^4.3.0",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
|
|
@ -36,7 +37,7 @@
|
||||||
"vitest": "^4.1.4"
|
"vitest": "^4.1.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
|
@ -1022,7 +1023,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -4766,7 +4766,6 @@
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
|
|
@ -9251,7 +9250,6 @@
|
||||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.0.0",
|
"@img/colour": "^1.0.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
|
|
@ -9295,7 +9293,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"shadcn": "^4.3.0",
|
"shadcn": "^4.3.0",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue