Add server-side avatar processing and responsive bottom nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-19 22:06:41 +02:00
parent 3170cfda18
commit 0bf6b96687
20 changed files with 608 additions and 147 deletions

View file

@ -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 110 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 (14) × 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

View file

@ -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;
} }

View file

@ -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 ? (

View file

@ -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>
); );

View 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>
</>
);
}

View 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));
}

View file

@ -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>

View file

@ -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;

View file

@ -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="pb-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&apos;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="pb-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="pb-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,8 +319,8 @@ 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="pb-0"> <Card className="pb-0">
@ -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>
); );
} }

View file

@ -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>
); );
} }

View file

@ -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.",
}, },
}; };

View 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.",
);
}

View file

@ -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`;

View file

@ -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),
}; };
} }

View file

@ -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
View 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,
},
});
}

View file

@ -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,
};
}

View file

@ -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
View file

@ -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"
}, },

View file

@ -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"