From e91f552c6053d1f86a15a13edc475c122b8ce3d4 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 19 Apr 2026 14:48:57 +0200 Subject: [PATCH] Add profile details and avatar uploads --- README.md | 5 +- app/dashboard/page.tsx | 23 +++- app/settings/actions.ts | 60 ++++++++++ app/settings/page.tsx | 23 +++- components/profile/profile-avatar.tsx | 70 +++++++++++ components/settings/settings-form.tsx | 113 +++++++++++++++++- components/ui/textarea.tsx | 16 +++ lib/feedback/status-messages.ts | 5 + lib/profile/avatar.ts | 18 +++ lib/profile/service.ts | 88 +++++++++++++- lib/profile/types.ts | 8 ++ ...add_profile_details_and_avatar_storage.sql | 89 ++++++++++++++ 12 files changed, 508 insertions(+), 10 deletions(-) create mode 100644 components/profile/profile-avatar.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 lib/profile/avatar.ts create mode 100644 supabase/migrations/20260419_add_profile_details_and_avatar_storage.sql diff --git a/README.md b/README.md index a9fef24..1f8fab4 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal: - niet-blokkerende waarschuwing bij budgetoverschrijding in planning en dashboard - eerste unit tests voor budget- en meterlogica via `Vitest` - korte onboardingflow voor eerste voorkeuren -- instellingen voor taal, timezone, reminders en zichtbaarheid van energiepunten +- instellingen voor profieltekst, avatar, taal, timezone, reminders en zichtbaarheid van energiepunten - `shadcn/ui` foundation voor knoppen, formulieren, kaarten en meldingen - `Dusk`-theme met dark-mode prioriteit, semantische oppervlakken en verbeterde focus-/toegankelijkheidsstijlen @@ -87,6 +87,7 @@ De huidige app gebruikt onder meer deze migraties: - `supabase/migrations/20260418_create_morning_check_ins.sql` - `supabase/migrations/20260418_add_budget_fields_to_morning_check_ins.sql` - `supabase/migrations/20260419_create_activities_and_reference_data.sql` +- `supabase/migrations/20260419_add_profile_details_and_avatar_storage.sql` Voer deze SQL uit in de Supabase SQL Editor of via de Supabase CLI voordat je de profile-, check-in- en budgetlagen lokaal test. @@ -122,7 +123,7 @@ zichtbaar als `NEXT_PUBLIC_ENABLE_TEST_WIZARD=true` staat. ## CI/CD -- `CI`: GitHub Actions draait automatisch `lint` en `build` op pull requests en op `main` +- `CI`: GitHub Actions draait automatisch `lint`, `test` en `build` op pull requests en op `main` - `CD`: Vercel deployt automatisch previews voor branches/PR's en productie vanaf `main` - Uitwerking: [docs/inspannings-monitor-cicd-en-deploy.md](/Users/janpetervisser/Development/third/docs/inspannings-monitor-cicd-en-deploy.md) diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index c1a641c..ced66d8 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -5,6 +5,7 @@ import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; import { AppShell } from "@/components/navigation/app-shell"; import { PageIntro } from "@/components/navigation/page-intro"; import { EnergyMeterCard } from "@/components/planning/energy-meter-card"; +import { ProfileAvatar } from "@/components/profile/profile-avatar"; import { Card, CardContent, @@ -116,13 +117,31 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps

Profiel

- {profileTitle} - + +
+ +
+ {profileTitle} + + {profile.tagline ?? "Nog geen korte profielregel toegevoegd."} + +
+
Taal `{profile.locale}` en timezone `{profile.timezone}` staan nu per gebruiker opgeslagen. + {profile.bio ? ( + + {profile.bio} + + ) : null}
diff --git a/app/settings/actions.ts b/app/settings/actions.ts index 4a661f4..d802fe2 100644 --- a/app/settings/actions.ts +++ b/app/settings/actions.ts @@ -3,17 +3,58 @@ import { redirect } from "next/navigation"; import { buildPathWithQuery } from "@/lib/auth/navigation"; import { + assertMaxLength, FormDataValidationError, getBooleanValue, getEnumValue, + getOptionalString, getOptionalTimeValue, } from "@/lib/forms/parse"; import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options"; +import { + isAllowedProfileAvatarMimeType, + PROFILE_AVATAR_MAX_BYTES, +} from "@/lib/profile/avatar"; import { saveSettingsForCurrentUser } from "@/lib/profile/service"; import type { SettingsSubmission } from "@/lib/profile/types"; const LOCALE_VALUES = ["nl-NL"] as const; const ONBOARDING_TIMEZONE_VALUES = ONBOARDING_TIMEZONE_OPTIONS.map((option) => option.value); +const MAX_DISPLAY_NAME_LENGTH = 80; +const MAX_TAGLINE_LENGTH = 160; +const MAX_BIO_LENGTH = 2000; + +function getOptionalBoundedString( + formData: FormData, + key: string, + maximumLength: number, + errorCode: string, +) { + const value = getOptionalString(formData, key); + + if (value === null) { + return null; + } + + return assertMaxLength(value, maximumLength, errorCode); +} + +function getOptionalAvatarFile(formData: FormData) { + const value = formData.get("avatar"); + + if (!(value instanceof File) || value.size === 0) { + return null; + } + + if ( + value.size > PROFILE_AVATAR_MAX_BYTES || + !isAllowedProfileAvatarMimeType(value.type) + ) { + throw new FormDataValidationError("invalid-avatar-file"); + } + + return value; +} function buildSettingsSubmission(formData: FormData): SettingsSubmission { const morningReminderEnabled = getBooleanValue( @@ -28,6 +69,25 @@ function buildSettingsSubmission(formData: FormData): SettingsSubmission { ); return { + displayName: getOptionalBoundedString( + formData, + "displayName", + MAX_DISPLAY_NAME_LENGTH, + "invalid-settings-input", + ), + tagline: getOptionalBoundedString( + formData, + "tagline", + MAX_TAGLINE_LENGTH, + "invalid-settings-input", + ), + bio: getOptionalBoundedString( + formData, + "bio", + MAX_BIO_LENGTH, + "invalid-settings-input", + ), + avatarFile: getOptionalAvatarFile(formData), locale: getEnumValue(formData, "locale", LOCALE_VALUES, "invalid-settings-input"), timezone: getEnumValue( formData, diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 12f7dd9..92ed1dd 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; import { AppShell } from "@/components/navigation/app-shell"; import { PageIntro } from "@/components/navigation/page-intro"; +import { ProfileAvatar } from "@/components/profile/profile-avatar"; import { SettingsForm } from "@/components/settings/settings-form"; import { Card, @@ -83,12 +84,30 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps)

Account

- {profileTitle} - + +
+ +
+ {profileTitle} + + {profileBundle.profile.tagline ?? "Nog geen 1-regelige profielregel."} + +
+
E-mailadres: {profileBundle.profile.email ?? authState.email ?? "Onbekend"} + {profileBundle.profile.bio ? ( + + {profileBundle.profile.bio} + + ) : null}
diff --git a/components/profile/profile-avatar.tsx b/components/profile/profile-avatar.tsx new file mode 100644 index 0000000..9866b1f --- /dev/null +++ b/components/profile/profile-avatar.tsx @@ -0,0 +1,70 @@ +import { cn } from "@/lib/utils"; + +type ProfileAvatarProps = { + avatarUrl: string | null; + displayName: string | null; + email?: string | null; + size?: "sm" | "md" | "lg"; + className?: string; +}; + +const avatarSizeClasses = { + sm: "size-12 text-sm", + md: "size-16 text-base", + lg: "size-20 text-xl", +} as const; + +function getProfileInitials(displayName: string | null, email?: string | null) { + const source = displayName?.trim() || email?.trim() || ""; + + if (!source) { + return "IM"; + } + + const parts = source + .split(/\s+/) + .map((part) => part.trim()) + .filter(Boolean); + + if (parts.length === 0) { + return "IM"; + } + + if (parts.length === 1) { + return parts[0].slice(0, 2).toUpperCase(); + } + + return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase(); +} + +export function ProfileAvatar({ + avatarUrl, + displayName, + email = null, + size = "md", + className, +}: ProfileAvatarProps) { + const initials = getProfileInitials(displayName, email); + const label = displayName || email || "Profielavatar"; + + return ( +
+ {avatarUrl ? ( + + ); +} diff --git a/components/settings/settings-form.tsx b/components/settings/settings-form.tsx index 2f33b5a..44a1721 100644 --- a/components/settings/settings-form.tsx +++ b/components/settings/settings-form.tsx @@ -3,6 +3,7 @@ import { useActionState, useState } from "react"; import { saveSettingsAction } from "@/app/settings/actions"; import { PreferenceHiddenFields } from "@/components/preferences/preference-hidden-fields"; +import { ProfileAvatar } from "@/components/profile/profile-avatar"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { @@ -23,7 +24,9 @@ import { } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options"; +import { PROFILE_AVATAR_MAX_BYTES } from "@/lib/profile/avatar"; import { usePreferenceDraft } from "@/lib/preferences/use-preferences-draft"; import type { ProfileBundle } from "@/lib/profile/types"; @@ -42,9 +45,19 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) { const [, formAction, isPending] = useActionState(saveSettingsAction, null); const [locale, setLocale] = useState(profileBundle.profile.locale); const { draft, updateDraft } = usePreferenceDraft(profileBundle); + const avatarLimitInMb = PROFILE_AVATAR_MAX_BYTES / (1024 * 1024); + const profileTitle = + profileBundle.profile.displayName ?? + profileBundle.profile.email ?? + "Ingelogde gebruiker"; return ( -
+ @@ -71,6 +84,104 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) { + + +

+ Profiel +

+ + Laat in een paar regels zien wie je bent + + + Voeg een naam, korte profielregel, langere beschrijving en een profielfoto toe. + Dit helpt straks ook bij demo-accounts en voorbeeldgebruik. + +
+ + + + +
+

{profileTitle}

+

+ {profileBundle.profile.tagline ?? "Nog geen 1-regelige introductie toegevoegd."} +

+
+ +
+ + +

+ JPG, PNG of WebP tot {avatarLimitInMb} MB. Een nieuw bestand vervangt je + huidige foto. +

+
+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ +