Add profile details and avatar uploads
This commit is contained in:
parent
8ab83205dd
commit
e91f552c60
12 changed files with 508 additions and 10 deletions
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
70
components/profile/profile-avatar.tsx
Normal file
70
components/profile/profile-avatar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
16
components/ui/textarea.tsx
Normal file
16
components/ui/textarea.tsx
Normal 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 };
|
||||||
|
|
@ -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
18
lib/profile/avatar.ts
Normal 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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
);
|
||||||
Loading…
Add table
Add a link
Reference in a new issue