Add profile details and avatar uploads

This commit is contained in:
Janpeter Visser 2026-04-19 14:48:57 +02:00
parent 8ab83205dd
commit e91f552c60
12 changed files with 508 additions and 10 deletions

View file

@ -30,7 +30,7 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal:
- niet-blokkerende waarschuwing bij budgetoverschrijding in planning en dashboard - niet-blokkerende waarschuwing bij budgetoverschrijding in planning en dashboard
- eerste unit tests voor budget- en meterlogica via `Vitest` - eerste unit tests voor budget- en meterlogica via `Vitest`
- korte onboardingflow voor eerste voorkeuren - 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 - `shadcn/ui` foundation voor knoppen, formulieren, kaarten en meldingen
- `Dusk`-theme met dark-mode prioriteit, semantische oppervlakken en verbeterde focus-/toegankelijkheidsstijlen - `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_create_morning_check_ins.sql`
- `supabase/migrations/20260418_add_budget_fields_to_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_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 Voer deze SQL uit in de Supabase SQL Editor of via de Supabase CLI voordat je
de profile-, check-in- en budgetlagen lokaal test. de profile-, check-in- en budgetlagen lokaal test.
@ -122,7 +123,7 @@ zichtbaar als `NEXT_PUBLIC_ENABLE_TEST_WIZARD=true` staat.
## CI/CD ## 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` - `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) - Uitwerking: [docs/inspannings-monitor-cicd-en-deploy.md](/Users/janpetervisser/Development/third/docs/inspannings-monitor-cicd-en-deploy.md)

View file

@ -5,6 +5,7 @@ import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
import { AppShell } from "@/components/navigation/app-shell"; import { AppShell } from "@/components/navigation/app-shell";
import { PageIntro } from "@/components/navigation/page-intro"; import { PageIntro } from "@/components/navigation/page-intro";
import { EnergyMeterCard } from "@/components/planning/energy-meter-card"; import { EnergyMeterCard } from "@/components/planning/energy-meter-card";
import { ProfileAvatar } from "@/components/profile/profile-avatar";
import { import {
Card, Card,
CardContent, CardContent,
@ -116,13 +117,31 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
<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-lg text-foreground">{profileTitle}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pb-6"> <CardContent className="space-y-4 pb-6">
<div className="flex items-center gap-4">
<ProfileAvatar
avatarUrl={profile.avatarUrl}
displayName={profile.displayName}
email={profile.email}
size="md"
/>
<div className="space-y-1">
<CardTitle className="text-lg text-foreground">{profileTitle}</CardTitle>
<CardDescription className="text-sm leading-7 text-muted-foreground">
{profile.tagline ?? "Nog geen korte profielregel toegevoegd."}
</CardDescription>
</div>
</div>
<CardDescription className="text-sm leading-7 text-muted-foreground"> <CardDescription className="text-sm leading-7 text-muted-foreground">
Taal `{profile.locale}` en timezone `{profile.timezone}` staan nu per Taal `{profile.locale}` en timezone `{profile.timezone}` staan nu per
gebruiker opgeslagen. gebruiker opgeslagen.
</CardDescription> </CardDescription>
{profile.bio ? (
<CardDescription className="whitespace-pre-line text-sm leading-7 text-muted-foreground">
{profile.bio}
</CardDescription>
) : null}
</CardContent> </CardContent>
</Card> </Card>

View file

@ -3,17 +3,58 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { buildPathWithQuery } from "@/lib/auth/navigation"; import { buildPathWithQuery } from "@/lib/auth/navigation";
import { import {
assertMaxLength,
FormDataValidationError, FormDataValidationError,
getBooleanValue, getBooleanValue,
getEnumValue, getEnumValue,
getOptionalString,
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 {
isAllowedProfileAvatarMimeType,
PROFILE_AVATAR_MAX_BYTES,
} from "@/lib/profile/avatar";
import { saveSettingsForCurrentUser } from "@/lib/profile/service"; import { saveSettingsForCurrentUser } from "@/lib/profile/service";
import type { SettingsSubmission } from "@/lib/profile/types"; import type { 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);
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 { function buildSettingsSubmission(formData: FormData): SettingsSubmission {
const morningReminderEnabled = getBooleanValue( const morningReminderEnabled = getBooleanValue(
@ -28,6 +69,25 @@ function buildSettingsSubmission(formData: FormData): SettingsSubmission {
); );
return { 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"), locale: getEnumValue(formData, "locale", LOCALE_VALUES, "invalid-settings-input"),
timezone: getEnumValue( timezone: getEnumValue(
formData, formData,

View file

@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
import { AppShell } from "@/components/navigation/app-shell"; import { AppShell } from "@/components/navigation/app-shell";
import { PageIntro } from "@/components/navigation/page-intro"; import { PageIntro } from "@/components/navigation/page-intro";
import { ProfileAvatar } from "@/components/profile/profile-avatar";
import { SettingsForm } from "@/components/settings/settings-form"; import { SettingsForm } from "@/components/settings/settings-form";
import { import {
Card, Card,
@ -83,12 +84,30 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps)
<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
</p> </p>
<CardTitle className="text-lg text-foreground">{profileTitle}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pb-6"> <CardContent className="space-y-4 pb-6">
<div className="flex items-center gap-4">
<ProfileAvatar
avatarUrl={profileBundle.profile.avatarUrl}
displayName={profileBundle.profile.displayName}
email={profileBundle.profile.email}
size="md"
/>
<div className="space-y-1">
<CardTitle className="text-lg text-foreground">{profileTitle}</CardTitle>
<CardDescription className="text-sm leading-7 text-muted-foreground">
{profileBundle.profile.tagline ?? "Nog geen 1-regelige profielregel."}
</CardDescription>
</div>
</div>
<CardDescription className="text-sm leading-7 text-muted-foreground"> <CardDescription className="text-sm leading-7 text-muted-foreground">
E-mailadres: {profileBundle.profile.email ?? authState.email ?? "Onbekend"} E-mailadres: {profileBundle.profile.email ?? authState.email ?? "Onbekend"}
</CardDescription> </CardDescription>
{profileBundle.profile.bio ? (
<CardDescription className="whitespace-pre-line text-sm leading-7 text-muted-foreground">
{profileBundle.profile.bio}
</CardDescription>
) : null}
</CardContent> </CardContent>
</Card> </Card>

View file

@ -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 (
<div
aria-label={label}
className={cn(
"relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/70 bg-muted/70 font-semibold tracking-[0.08em] text-foreground shadow-[var(--shadow-1)]",
avatarSizeClasses[size],
className,
)}
role="img"
>
{avatarUrl ? (
<div
aria-hidden="true"
className="absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: `url(${avatarUrl})` }}
/>
) : null}
<span className={cn("relative z-10", avatarUrl ? "sr-only" : null)}>{initials}</span>
</div>
);
}

View file

@ -3,6 +3,7 @@
import { useActionState, useState } from "react"; import { useActionState, useState } from "react";
import { saveSettingsAction } from "@/app/settings/actions"; import { saveSettingsAction } from "@/app/settings/actions";
import { PreferenceHiddenFields } from "@/components/preferences/preference-hidden-fields"; import { PreferenceHiddenFields } from "@/components/preferences/preference-hidden-fields";
import { ProfileAvatar } from "@/components/profile/profile-avatar";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -23,7 +24,9 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator"; 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 { 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 { usePreferenceDraft } from "@/lib/preferences/use-preferences-draft"; import { usePreferenceDraft } from "@/lib/preferences/use-preferences-draft";
import type { ProfileBundle } from "@/lib/profile/types"; import type { ProfileBundle } from "@/lib/profile/types";
@ -42,9 +45,19 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
const [, formAction, isPending] = useActionState(saveSettingsAction, null); const [, formAction, isPending] = useActionState(saveSettingsAction, null);
const [locale, setLocale] = useState(profileBundle.profile.locale); const [locale, setLocale] = useState(profileBundle.profile.locale);
const { draft, updateDraft } = usePreferenceDraft(profileBundle); const { draft, updateDraft } = usePreferenceDraft(profileBundle);
const avatarLimitInMb = PROFILE_AVATAR_MAX_BYTES / (1024 * 1024);
const profileTitle =
profileBundle.profile.displayName ??
profileBundle.profile.email ??
"Ingelogde gebruiker";
return ( return (
<form action={formAction} className="space-y-6" aria-busy={isPending}> <form
action={formAction}
className="space-y-6"
aria-busy={isPending}
encType="multipart/form-data"
>
<input type="hidden" name="locale" value={locale} /> <input type="hidden" name="locale" value={locale} />
<PreferenceHiddenFields draft={draft} /> <PreferenceHiddenFields draft={draft} />
@ -71,6 +84,104 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
</CardContent> </CardContent>
</Card> </Card>
<Card className="py-0">
<CardHeader className="pb-0">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
Profiel
</p>
<CardTitle className="text-2xl text-foreground">
Laat in een paar regels zien wie je bent
</CardTitle>
<CardDescription className="max-w-2xl text-sm leading-7 text-muted-foreground">
Voeg een naam, korte profielregel, langere beschrijving en een profielfoto toe.
Dit helpt straks ook bij demo-accounts en voorbeeldgebruik.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 pb-6 lg:grid-cols-[16rem_1fr]">
<Card tone="subtle" className="py-0 shadow-none">
<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">
<Label htmlFor="display-name" className="text-foreground">
Weergavenaam
</Label>
<Input
id="display-name"
name="displayName"
defaultValue={profileBundle.profile.displayName ?? ""}
disabled={isPending}
maxLength={80}
className="h-12 rounded-[1.25rem] bg-background/80 px-4 text-base md:text-base"
placeholder="Bijvoorbeeld Jan Peter"
/>
</div>
<div className="space-y-2">
<Label htmlFor="tagline" className="text-foreground">
Wie ben je in 1 regel?
</Label>
<Input
id="tagline"
name="tagline"
defaultValue={profileBundle.profile.tagline ?? ""}
disabled={isPending}
maxLength={160}
className="h-12 rounded-[1.25rem] bg-background/80 px-4 text-base md:text-base"
placeholder="Bijvoorbeeld: rustige planner die energie slim wil verdelen"
/>
</div>
<div className="space-y-2">
<Label htmlFor="bio" className="text-foreground">
Korte omschrijving
</Label>
<Textarea
id="bio"
name="bio"
defaultValue={profileBundle.profile.bio ?? ""}
disabled={isPending}
maxLength={2000}
className="min-h-36 rounded-[1.5rem] bg-background/80 px-4 py-3 text-base md:text-base"
placeholder="Vertel in een paar zinnen wat belangrijk is in je dagstructuur, energie of ritme."
/>
</div>
</div>
</CardContent>
</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="py-0">
<CardHeader className="pb-0"> <CardHeader className="pb-0">

View file

@ -0,0 +1,16 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
className={cn(
"flex min-h-28 w-full rounded-[var(--radius)] border border-input bg-transparent px-3 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground hover:border-border/80 focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-4 aria-invalid:ring-destructive/16 md:text-sm dark:bg-input/30 dark:hover:bg-input/45 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/24",
className,
)}
{...props}
/>
);
}
export { Textarea };

View file

@ -45,6 +45,11 @@ const settingsErrorToasts: Record<string, StatusToast> = {
title: "Instellingen niet opgeslagen", title: "Instellingen niet opgeslagen",
message: "Controleer je tijd, timezone en voorkeurvelden en probeer het opnieuw.", message: "Controleer je tijd, timezone en voorkeurvelden en probeer het opnieuw.",
}, },
"invalid-avatar-file": {
variant: "error",
title: "Profielfoto niet opgeslagen",
message: "Gebruik een JPG, PNG of WebP-bestand tot 2 MB en probeer het opnieuw.",
},
}; };
const onboardingErrorToasts: Record<string, StatusToast> = { const onboardingErrorToasts: Record<string, StatusToast> = {

18
lib/profile/avatar.ts Normal file
View file

@ -0,0 +1,18 @@
const PROFILE_AVATAR_ALLOWED_MIME_TYPES = [
"image/jpeg",
"image/png",
"image/webp",
] as const;
export const PROFILE_AVATAR_BUCKET = "profile-avatars";
export const PROFILE_AVATAR_MAX_BYTES = 2 * 1024 * 1024;
export function getProfileAvatarPath(userId: string) {
return `${userId}/avatar`;
}
export function isAllowedProfileAvatarMimeType(value: string) {
return PROFILE_AVATAR_ALLOWED_MIME_TYPES.includes(
value as (typeof PROFILE_AVATAR_ALLOWED_MIME_TYPES)[number],
);
}

View file

@ -1,5 +1,9 @@
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 {
getProfileAvatarPath,
PROFILE_AVATAR_BUCKET,
} from "@/lib/profile/avatar";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import type { import type {
OnboardingSubmission, OnboardingSubmission,
@ -15,6 +19,9 @@ type ProfileRow = {
id: string; id: string;
email: string | null; email: string | null;
display_name: string | null; display_name: string | null;
tagline: string | null;
bio: string | null;
avatar_path: string | null;
locale: string; locale: string;
timezone: string; timezone: string;
onboarding_seen: boolean; onboarding_seen: boolean;
@ -37,6 +44,9 @@ type ProfileInsert = {
id: string; id: string;
email: string | null; email: string | null;
display_name: string | null; display_name: string | null;
tagline: string | null;
bio: string | null;
avatar_path: string | null;
locale: string; locale: string;
timezone: string; timezone: string;
onboarding_seen: boolean; onboarding_seen: boolean;
@ -52,7 +62,7 @@ type UserSettingsInsert = {
}; };
const PROFILE_COLUMNS = const PROFILE_COLUMNS =
"id, email, display_name, locale, timezone, onboarding_seen, onboarding_completed, created_at, updated_at"; "id, email, display_name, tagline, bio, avatar_path, locale, timezone, onboarding_seen, onboarding_completed, created_at, updated_at";
const USER_SETTINGS_COLUMNS = const USER_SETTINGS_COLUMNS =
"profile_id, morning_reminder_enabled, morning_reminder_time, reflection_reminder_enabled, show_energy_points, created_at, updated_at"; "profile_id, morning_reminder_enabled, morning_reminder_time, reflection_reminder_enabled, show_energy_points, created_at, updated_at";
@ -60,11 +70,18 @@ const DEFAULT_LOCALE = "nl-NL";
const DEFAULT_TIMEZONE = "Europe/Amsterdam"; const DEFAULT_TIMEZONE = "Europe/Amsterdam";
const SUPPORTED_LOCALES = new Set([DEFAULT_LOCALE]); const SUPPORTED_LOCALES = new Set([DEFAULT_LOCALE]);
function mapProfileRow(row: ProfileRow): ProfileRecord { async function buildProfileRecord(
supabase: SupabaseServerClient,
row: ProfileRow,
): Promise<ProfileRecord> {
return { return {
id: row.id, id: row.id,
email: row.email, email: row.email,
displayName: row.display_name, displayName: row.display_name,
tagline: row.tagline,
bio: row.bio,
avatarPath: row.avatar_path,
avatarUrl: await getProfileAvatarUrl(supabase, row.avatar_path),
locale: row.locale, locale: row.locale,
timezone: row.timezone, timezone: row.timezone,
onboardingSeen: row.onboarding_seen, onboardingSeen: row.onboarding_seen,
@ -94,6 +111,9 @@ export function buildDefaultProfileFromClaims(user: {
id: user.id, id: user.id,
email: user.email, email: user.email,
display_name: null, display_name: null,
tagline: null,
bio: null,
avatar_path: null,
locale: DEFAULT_LOCALE, locale: DEFAULT_LOCALE,
timezone: DEFAULT_TIMEZONE, timezone: DEFAULT_TIMEZONE,
onboarding_seen: false, onboarding_seen: false,
@ -112,6 +132,16 @@ export function buildDefaultSettings(profileId: string): UserSettingsInsert {
} }
function normalizeDisplayName(value: string | null) { function normalizeDisplayName(value: string | null) {
const trimmedValue = value?.trim() ?? "";
return trimmedValue ? trimmedValue.replace(/\s+/g, " ") : null;
}
function normalizeTagline(value: string | null) {
const trimmedValue = value?.trim() ?? "";
return trimmedValue ? trimmedValue.replace(/\s+/g, " ") : null;
}
function normalizeBio(value: string | null) {
const trimmedValue = value?.trim() ?? ""; const trimmedValue = value?.trim() ?? "";
return trimmedValue ? trimmedValue : null; return trimmedValue ? trimmedValue : null;
} }
@ -133,6 +163,48 @@ function resolveTimezone(value: string) {
return isSupportedOnboardingTimezone(value) ? value : DEFAULT_TIMEZONE; return isSupportedOnboardingTimezone(value) ? value : DEFAULT_TIMEZONE;
} }
async function getProfileAvatarUrl(
supabase: SupabaseServerClient,
avatarPath: string | null,
) {
if (!avatarPath) {
return null;
}
const { data, error } = await supabase.storage
.from(PROFILE_AVATAR_BUCKET)
.createSignedUrl(avatarPath, 60 * 60);
if (error) {
return null;
}
return data.signedUrl;
}
async function uploadProfileAvatar(
supabase: SupabaseServerClient,
userId: string,
file: File,
) {
const avatarPath = getProfileAvatarPath(userId);
const fileBytes = await file.arrayBuffer();
const { error } = await supabase.storage
.from(PROFILE_AVATAR_BUCKET)
.upload(avatarPath, fileBytes, {
cacheControl: "3600",
contentType: file.type,
upsert: true,
});
if (error) {
throw new Error(`Profielfoto kon niet worden geupload: ${error.message}`);
}
return avatarPath;
}
async function readProfileRow( async function readProfileRow(
supabase: SupabaseServerClient, supabase: SupabaseServerClient,
userId: string, userId: string,
@ -305,14 +377,24 @@ export async function saveSettingsForCurrentUser(
const supabase = await createClient(); const supabase = await createClient();
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 tagline = normalizeTagline(submission.tagline);
const bio = normalizeBio(submission.bio);
const morningReminderTime = normalizeReminderTime( const morningReminderTime = normalizeReminderTime(
submission.morningReminderTime, submission.morningReminderTime,
submission.morningReminderEnabled, submission.morningReminderEnabled,
); );
const avatarPath = submission.avatarFile
? await uploadProfileAvatar(supabase, user.id, submission.avatarFile)
: null;
const { error: profileError } = await supabase const { error: profileError } = await supabase
.from("profiles") .from("profiles")
.update({ .update({
display_name: displayName,
tagline,
bio,
...(avatarPath ? { avatar_path: avatarPath } : {}),
locale, locale,
timezone, timezone,
}) })
@ -375,7 +457,7 @@ export async function ensureProfileBundleForCurrentUser(): Promise<ProfileBundle
} }
return { return {
profile: mapProfileRow(profileRow), profile: await buildProfileRecord(supabase, profileRow),
settings: mapUserSettingsRow(userSettingsRow), settings: mapUserSettingsRow(userSettingsRow),
}; };
} }

View file

@ -2,6 +2,10 @@ export type ProfileRecord = {
id: string; id: string;
email: string | null; email: string | null;
displayName: string | null; displayName: string | null;
tagline: string | null;
bio: string | null;
avatarPath: string | null;
avatarUrl: string | null;
locale: string; locale: string;
timezone: string; timezone: string;
onboardingSeen: boolean; onboardingSeen: boolean;
@ -35,6 +39,10 @@ export type OnboardingSubmission = {
}; };
export type SettingsSubmission = { export type SettingsSubmission = {
displayName: string | null;
tagline: string | null;
bio: string | null;
avatarFile: File | null;
locale: string; locale: string;
timezone: string; timezone: string;
morningReminderEnabled: boolean; morningReminderEnabled: boolean;

View file

@ -0,0 +1,89 @@
alter table public.profiles
add column if not exists tagline text,
add column if not exists bio text,
add column if not exists avatar_path text;
alter table public.profiles
drop constraint if exists profiles_tagline_length_check;
alter table public.profiles
add constraint profiles_tagline_length_check
check (tagline is null or char_length(tagline) <= 160);
alter table public.profiles
drop constraint if exists profiles_bio_length_check;
alter table public.profiles
add constraint profiles_bio_length_check
check (bio is null or char_length(bio) <= 2000);
alter table public.profiles
drop constraint if exists profiles_avatar_path_length_check;
alter table public.profiles
add constraint profiles_avatar_path_length_check
check (avatar_path is null or char_length(avatar_path) <= 255);
insert into storage.buckets (
id,
name,
public,
file_size_limit,
allowed_mime_types
)
values (
'profile-avatars',
'profile-avatars',
false,
2097152,
array['image/jpeg', 'image/png', 'image/webp']
)
on conflict (id) do update
set
public = excluded.public,
file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types;
drop policy if exists "profile_avatars_select_own" on storage.objects;
create policy "profile_avatars_select_own"
on storage.objects
for select
to authenticated
using (
bucket_id = 'profile-avatars'
and auth.uid()::text = (storage.foldername(name))[1]
);
drop policy if exists "profile_avatars_insert_own" on storage.objects;
create policy "profile_avatars_insert_own"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'profile-avatars'
and auth.uid()::text = (storage.foldername(name))[1]
);
drop policy if exists "profile_avatars_update_own" on storage.objects;
create policy "profile_avatars_update_own"
on storage.objects
for update
to authenticated
using (
bucket_id = 'profile-avatars'
and auth.uid()::text = (storage.foldername(name))[1]
)
with check (
bucket_id = 'profile-avatars'
and auth.uid()::text = (storage.foldername(name))[1]
);
drop policy if exists "profile_avatars_delete_own" on storage.objects;
create policy "profile_avatars_delete_own"
on storage.objects
for delete
to authenticated
using (
bucket_id = 'profile-avatars'
and auth.uid()::text = (storage.foldername(name))[1]
);