104 lines
3.1 KiB
TypeScript
104 lines
3.1 KiB
TypeScript
"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<ProfileResult> {
|
|
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<UploadAvatarResult> {
|
|
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<ProfileResult> {
|
|
const userId = await requireUserId();
|
|
|
|
await prisma.user.update({ where: { id: userId }, data: { avatar: null } });
|
|
|
|
revalidateProfile();
|
|
return {};
|
|
}
|