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

@ -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
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
Profiel
</p>
<CardTitle className="text-lg text-foreground">{profileTitle}</CardTitle>
</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">
Taal `{profile.locale}` en timezone `{profile.timezone}` staan nu per
gebruiker opgeslagen.
</CardDescription>
{profile.bio ? (
<CardDescription className="whitespace-pre-line text-sm leading-7 text-muted-foreground">
{profile.bio}
</CardDescription>
) : null}
</CardContent>
</Card>

View file

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

View file

@ -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)
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
Account
</p>
<CardTitle className="text-lg text-foreground">{profileTitle}</CardTitle>
</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">
E-mailadres: {profileBundle.profile.email ?? authState.email ?? "Onbekend"}
</CardDescription>
{profileBundle.profile.bio ? (
<CardDescription className="whitespace-pre-line text-sm leading-7 text-muted-foreground">
{profileBundle.profile.bio}
</CardDescription>
) : null}
</CardContent>
</Card>