"use server"; import { revalidatePath } from "next/cache"; import sharp from "sharp"; import { prisma } from "@/lib/db"; import { requireUserId } from "@/lib/auth-helpers"; import { ProfileNameSchema } from "@/lib/validation/profile"; /** * Refresh both the Profile page (its form's initial values) and the (app) * layout (the sidebar shows the user's name/photo) after a change. * "layout" type invalidates the whole (app) tree -- see * next/docs revalidatePath "Revalidating a Layout path". */ function revalidateProfile() { revalidatePath("/profile"); revalidatePath("/", "layout"); } export type ProfileResult = { error?: string }; /** * Sets the user's first and last name (either may be blank). Blank values * are stored as null so "cleared" and "never set" mean the same thing. */ export async function updateProfileNames( firstName: string, lastName: string ): Promise { const userId = await requireUserId(); const parsed = ProfileNameSchema.parse({ firstName, lastName }); await prisma.user.update({ where: { id: userId }, data: { firstName: parsed.firstName || null, lastName: parsed.lastName || null, }, }); revalidateProfile(); return {}; } const MAX_AVATAR_BYTES = 10 * 1024 * 1024; const ALLOWED_AVATAR_TYPES = new Set([ "image/jpeg", "image/png", "image/webp", "image/gif", ]); // Avatars render at most a couple of dozen CSS pixels; 256x256 covers // retina without bloat. Re-encoded as JPEG so every row stored in the DB // has the same shape regardless of the upload's original format. const AVATAR_SIZE = 256; export type UploadAvatarResult = { avatar?: string; error?: string }; /** * Stores the user's profile photo. The upload is resized to 256x256 * (cover crop, EXIF-rotated) and re-encoded as JPEG server-side, then * saved as a data URL on the User row -- the app's container filesystem * is ephemeral (only the Postgres volume survives deploys), so the image * lives in the database rather than on disk. */ export async function uploadAvatar(file: File): Promise { const userId = await requireUserId(); if (!ALLOWED_AVATAR_TYPES.has(file.type)) { return { error: "Please choose a JPEG, PNG, WebP, or GIF image." }; } if (file.size === 0) return { error: "That file is empty." }; if (file.size > MAX_AVATAR_BYTES) { return { error: "Image must be 10 MB or smaller." }; } let encoded: Buffer; try { encoded = await sharp(Buffer.from(await file.arrayBuffer())) .rotate() // honor EXIF orientation from phones' photos .resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover" }) .jpeg({ quality: 85 }) .toBuffer(); } catch { return { error: "That doesn't look like a valid image." }; } const avatar = `data:image/jpeg;base64,${encoded.toString("base64")}`; await prisma.user.update({ where: { id: userId }, data: { avatar } }); revalidateProfile(); return { avatar }; } export async function removeAvatar(): Promise { const userId = await requireUserId(); await prisma.user.update({ where: { id: userId }, data: { avatar: null } }); revalidateProfile(); return {}; }