From 1ff894a6c06c9da9427cee06d23c9dd47036a5dd Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Sat, 25 Apr 2026 13:30:38 +0200 Subject: [PATCH] feat: gebruikersprofiel met avatar, bio en uitgebreide beschrijving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Schema: bio (160), bio_detail (2000) en avatar_data (bytea) op User - POST /api/profile/avatar: validatie MIME-type + max 12 MB vóór verwerking, Sharp resize naar max 700x700 (fit inside), output WebP q85, opgeslagen als bytea in Neon - GET /api/profile/avatar: serveert avatar met Cache-Control private 1u - updateProfileAction: slaat bio en bio_detail op via Server Action + Zod - ProfileEditor client component: avatar preview, upload met client-side validatie, bio-velden met tekenlimieten - Settings page: profiel-sectie bovenaan, uitgeschakeld voor demo-gebruiker - next.config: sharp als serverExternalPackage Co-Authored-By: Claude Sonnet 4.6 --- actions/profile.ts | 40 +++++ app/(app)/settings/page.tsx | 25 ++- app/api/profile/avatar/route.ts | 73 +++++++++ components/settings/profile-editor.tsx | 155 ++++++++++++++++++ next.config.ts | 1 + .../migration.sql | 4 + prisma/schema.prisma | 3 + 7 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 actions/profile.ts create mode 100644 app/api/profile/avatar/route.ts create mode 100644 components/settings/profile-editor.tsx create mode 100644 prisma/migrations/20260425112843_add_user_profile/migration.sql diff --git a/actions/profile.ts b/actions/profile.ts new file mode 100644 index 0000000..acc62b2 --- /dev/null +++ b/actions/profile.ts @@ -0,0 +1,40 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { z } from 'zod' +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +const profileSchema = z.object({ + bio: z.string().max(160).optional(), + bio_detail: z.string().max(2000).optional(), +}) + +export async function updateProfileAction(_prevState: unknown, formData: FormData) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = profileSchema.safeParse({ + bio: (formData.get('bio') as string) || undefined, + bio_detail: (formData.get('bio_detail') as string) || undefined, + }) + if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } + + await prisma.user.update({ + where: { id: session.userId }, + data: { + bio: parsed.data.bio ?? null, + bio_detail: parsed.data.bio_detail ?? null, + }, + }) + + revalidatePath('/settings') + return { success: true } +} diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 41b0873..68af447 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -4,13 +4,17 @@ import { SessionData, sessionOptions } from '@/lib/session' import { prisma } from '@/lib/prisma' import { RoleManager } from '@/components/settings/role-manager' import { LeaveProductButton } from '@/components/settings/leave-product-button' +import { ProfileEditor } from '@/components/settings/profile-editor' import Link from 'next/link' export default async function SettingsPage() { const session = await getIronSession(await cookies(), sessionOptions) const [user, userRoles, memberships] = await Promise.all([ - prisma.user.findUnique({ where: { id: session.userId }, select: { username: true } }), + prisma.user.findUnique({ + where: { id: session.userId }, + select: { username: true, bio: true, bio_detail: true, avatar_data: true, updated_at: true }, + }), prisma.userRole.findMany({ where: { user_id: session.userId } }), prisma.productMember.findMany({ where: { user_id: session.userId }, @@ -24,6 +28,25 @@ export default async function SettingsPage() {

Instellingen

+
+
+

Profiel

+

+ Zichtbaar voor teamleden die je toevoegen aan een product backlog. +

+
+ {session.isDemo ? ( +

Niet beschikbaar in demo-modus.

+ ) : ( + + )} +
+

Account

diff --git a/app/api/profile/avatar/route.ts b/app/api/profile/avatar/route.ts new file mode 100644 index 0000000..16e485f --- /dev/null +++ b/app/api/profile/avatar/route.ts @@ -0,0 +1,73 @@ +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import sharp from 'sharp' +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' + +const MAX_BYTES = 12 * 1024 * 1024 +const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']) + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +export async function POST(request: Request) { + const session = await getSession() + if (!session.userId) { + return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) + } + if (session.isDemo) { + return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) + } + + const formData = await request.formData() + const file = formData.get('avatar') as File | null + if (!file || file.size === 0) { + return Response.json({ error: 'Geen bestand ontvangen' }, { status: 400 }) + } + + if (!ALLOWED_TYPES.has(file.type)) { + return Response.json({ error: 'Alleen JPEG, PNG en WebP zijn toegestaan' }, { status: 400 }) + } + + if (file.size > MAX_BYTES) { + return Response.json({ error: 'Bestand is groter dan 12 MB' }, { status: 400 }) + } + + const input = Buffer.from(await file.arrayBuffer()) + + const processed = await sharp(input) + .resize(700, 700, { fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 85 }) + .toBuffer() + + await prisma.user.update({ + where: { id: session.userId }, + data: { avatar_data: new Uint8Array(processed) }, + }) + + return Response.json({ ok: true }) +} + +export async function GET() { + const session = await getSession() + if (!session.userId) { + return new Response(null, { status: 401 }) + } + + const user = await prisma.user.findUnique({ + where: { id: session.userId }, + select: { avatar_data: true }, + }) + + if (!user?.avatar_data) { + return new Response(null, { status: 404 }) + } + + return new Response(user.avatar_data, { + headers: { + 'Content-Type': 'image/webp', + 'Cache-Control': 'private, max-age=3600', + }, + }) +} diff --git a/components/settings/profile-editor.tsx b/components/settings/profile-editor.tsx new file mode 100644 index 0000000..164f736 --- /dev/null +++ b/components/settings/profile-editor.tsx @@ -0,0 +1,155 @@ +'use client' + +import { useActionState, useRef, useState } from 'react' +import Image from 'next/image' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { updateProfileAction } from '@/actions/profile' + +interface ProfileEditorProps { + bio: string | null + bioDetail: string | null + hasAvatar: boolean + avatarVersion: number +} + +const ALLOWED = ['image/jpeg', 'image/png', 'image/webp'] +const MAX_BYTES = 12 * 1024 * 1024 + +export function ProfileEditor({ bio, bioDetail, hasAvatar, avatarVersion }: ProfileEditorProps) { + const [state, formAction, isPending] = useActionState(updateProfileAction, null) + const [avatarSrc, setAvatarSrc] = useState( + hasAvatar ? `/api/profile/avatar?v=${avatarVersion}` : null + ) + const [uploadError, setUploadError] = useState(null) + const [uploading, setUploading] = useState(false) + const fileRef = useRef(null) + + async function handleFile(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + + setUploadError(null) + + if (!ALLOWED.includes(file.type)) { + setUploadError('Alleen JPEG, PNG en WebP zijn toegestaan') + return + } + if (file.size > MAX_BYTES) { + setUploadError('Bestand is groter dan 12 MB') + return + } + + const reader = new FileReader() + reader.onload = (ev) => setAvatarSrc(ev.target?.result as string) + reader.readAsDataURL(file) + + setUploading(true) + try { + const fd = new FormData() + fd.append('avatar', file) + const res = await fetch('/api/profile/avatar', { method: 'POST', body: fd }) + const data = await res.json() + if (!res.ok) { + setUploadError(data.error ?? 'Upload mislukt') + setAvatarSrc(hasAvatar ? `/api/profile/avatar?v=${avatarVersion}` : null) + } + } finally { + setUploading(false) + if (fileRef.current) fileRef.current.value = '' + } + } + + return ( +

+
+ + +
+ +

JPEG, PNG of WebP — max 12 MB

+ {uploadError &&

{uploadError}

} +
+ + +
+ +
+
+ + +

Max. 160 tekens

+
+ +
+ +