Add profile details and avatar uploads
This commit is contained in:
parent
8ab83205dd
commit
e91f552c60
12 changed files with 508 additions and 10 deletions
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 { 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 (
|
||||
<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} />
|
||||
<PreferenceHiddenFields draft={draft} />
|
||||
|
||||
|
|
@ -71,6 +84,104 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
|
|||
</CardContent>
|
||||
</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">
|
||||
<Card className="py-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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue