Profile #7
|
|
@ -26,6 +26,9 @@ given instance automatically becomes its administrator.
|
|||
password, or delete their account
|
||||
- control how the site handles new sign-ups (see [Sign-up modes](#sign-up-modes)
|
||||
below)
|
||||
- **Profile page** (`/profile`): set a first/last name and a profile photo,
|
||||
shown in the menu on the left (the photo replaces the initial-letter
|
||||
avatar when set)
|
||||
|
||||
## Tech stack
|
||||
|
||||
|
|
|
|||
|
|
@ -23,22 +23,36 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||
// board (categories/groups/todos) is fetched separately by its own page.
|
||||
// `theme` is stored as free text; the set of valid names is enforced
|
||||
// client-side (ProjectThemeSchema) so a plain assertion is safe here.
|
||||
const projects: ProjectDTO[] = (
|
||||
await prisma.project.findMany({
|
||||
const [projects, profile] = await Promise.all([
|
||||
prisma.project.findMany({
|
||||
where: { ownerId: session.user.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true, title: true, theme: true },
|
||||
})
|
||||
).map((p) => ({
|
||||
}),
|
||||
// The user's profile for the sidebar's bottom user block (name + photo,
|
||||
// both optional); the /profile page fetches the same row itself.
|
||||
prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { firstName: true, lastName: true, avatar: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const projectList: ProjectDTO[] = projects.map((p) => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
theme: p.theme as ThemeName | null,
|
||||
}));
|
||||
|
||||
const userName =
|
||||
[profile?.firstName, profile?.lastName]
|
||||
.filter((part): part is string => Boolean(part && part.trim()))
|
||||
.map((part) => part.trim())
|
||||
.join(" ") || null;
|
||||
|
||||
return (
|
||||
<SideNavProvider>
|
||||
<ScheduledPanelProvider>
|
||||
<ProjectsProvider initialProjects={projects}>
|
||||
<ProjectsProvider initialProjects={projectList}>
|
||||
<BoardViewProvider>
|
||||
<HoldOnCompleteProvider>
|
||||
{/* Responsive shell:
|
||||
|
|
@ -51,7 +65,12 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||
toolbar instead of the stale 100vh. */}
|
||||
<div className="flex h-dvh flex-col md:h-screen md:min-w-[860px] md:flex-row md:overflow-x-auto">
|
||||
<MobileTopBar />
|
||||
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
|
||||
<SideNav
|
||||
userEmail={session.user.email ?? ""}
|
||||
userName={userName}
|
||||
avatar={profile?.avatar ?? null}
|
||||
role={session.user.role}
|
||||
/>
|
||||
<main className="min-h-0 flex-1 overflow-auto">{children}</main>
|
||||
<ScheduledPanel />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { ProfileDTO } from "@/types/profile";
|
||||
import { ProfileForm } from "@/components/profile/profile-form";
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const session = await auth();
|
||||
// Defense in depth: proxy.ts already redirects unauthenticated requests,
|
||||
// but every protected data boundary should check for itself too.
|
||||
if (!session?.user) redirect("/login");
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { firstName: true, lastName: true, avatar: true },
|
||||
});
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const profile: ProfileDTO = {
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
avatar: user.avatar,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4 p-4">
|
||||
<div className="px-1">
|
||||
<h1 className="font-heading text-xl font-bold tracking-tight">Profile</h1>
|
||||
<p className="mt-0.5 text-[13px] text-muted-foreground">
|
||||
Your name and photo, shown in the menu on the left.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProfileForm initial={profile} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronLeft, ChevronRight, LogOut, NotebookPen } from "lucide-react";
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
||||
|
||||
|
|
@ -14,6 +15,42 @@ import { ThemeToggle } from "@/components/theme/theme-toggle";
|
|||
import { logout } from "@/lib/actions/auth";
|
||||
import { Role } from "@/lib/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* The signed-in user's circle at the bottom of the nav: their photo when
|
||||
* they've set one, otherwise the letter fallback -- and a link to the
|
||||
* Profile page either way.
|
||||
*/
|
||||
function UserAvatar({
|
||||
userName,
|
||||
userEmail,
|
||||
avatar,
|
||||
sizeClass,
|
||||
}: {
|
||||
userName: string | null;
|
||||
userEmail: string;
|
||||
avatar: string | null;
|
||||
sizeClass: string;
|
||||
}) {
|
||||
if (avatar) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- data-URL avatars, no image-optimization pipeline in this self-hosted app
|
||||
<img
|
||||
src={avatar}
|
||||
alt={userName ?? userEmail}
|
||||
className={`${sizeClass} shrink-0 rounded-full object-cover`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const letter = (userName?.trim()[0] ?? userEmail[0] ?? "?").slice(0, 1);
|
||||
return (
|
||||
<span
|
||||
className={`${sizeClass} flex shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary uppercase`}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual nav content, shared by the desktop aside and the mobile
|
||||
* hamburger drawer so the two never drift apart. `collapsed` is always
|
||||
|
|
@ -22,11 +59,15 @@ import { Role } from "@/lib/generated/prisma/enums";
|
|||
function SideNavContent({
|
||||
collapsed,
|
||||
userEmail,
|
||||
userName,
|
||||
avatar,
|
||||
role,
|
||||
themeMenu,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
userEmail: string;
|
||||
userName: string | null;
|
||||
avatar: string | null;
|
||||
role: Role;
|
||||
// Where the theme picker menu opens from its trigger -- right of the
|
||||
// trigger in the desktop sidebar, below it in the mobile drawer (a
|
||||
|
|
@ -34,6 +75,9 @@ function SideNavContent({
|
|||
themeMenu: { side: "right" | "bottom"; align: "start" | "end" };
|
||||
}) {
|
||||
const adminItem = role === Role.ADMIN ? [ADMIN_NAV_ITEM] : [];
|
||||
// The user's display label: their name when set, otherwise the email
|
||||
// (same as before profiles existed).
|
||||
const userLabel = userName || userEmail;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -85,11 +129,38 @@ function SideNavContent({
|
|||
</div>
|
||||
|
||||
<div className={cn("flex items-center gap-2 border-t px-4 py-3", collapsed && "justify-center px-0")}>
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary uppercase">
|
||||
{(userEmail[0] ?? "?").slice(0, 1)}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<span className="truncate text-[13px] font-medium text-muted-foreground">{userEmail}</span>
|
||||
{collapsed ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Link href="/profile" aria-label={`View profile (${userLabel})`} />
|
||||
}
|
||||
>
|
||||
<UserAvatar
|
||||
userName={userName}
|
||||
userEmail={userEmail}
|
||||
avatar={avatar}
|
||||
sizeClass="size-7"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{userLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Link
|
||||
href="/profile"
|
||||
className="flex min-w-0 flex-1 items-center gap-2"
|
||||
aria-label="View profile"
|
||||
>
|
||||
<UserAvatar
|
||||
userName={userName}
|
||||
userEmail={userEmail}
|
||||
avatar={avatar}
|
||||
sizeClass="size-7"
|
||||
/>
|
||||
<span className="truncate text-[13px] font-medium text-muted-foreground">
|
||||
{userLabel}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -100,7 +171,17 @@ function SideNavContent({
|
|||
* Mobile (< md) hamburger drawer: the full side nav in a left sheet.
|
||||
* Tapping any link closes it; the backdrop and Escape close it too.
|
||||
*/
|
||||
function MobileNavDrawer({ userEmail, role }: { userEmail: string; role: Role }) {
|
||||
function MobileNavDrawer({
|
||||
userEmail,
|
||||
userName,
|
||||
avatar,
|
||||
role,
|
||||
}: {
|
||||
userEmail: string;
|
||||
userName: string | null;
|
||||
avatar: string | null;
|
||||
role: Role;
|
||||
}) {
|
||||
const { mobileOpen, setMobileOpen } = useSideNav();
|
||||
|
||||
return (
|
||||
|
|
@ -122,14 +203,31 @@ function MobileNavDrawer({ userEmail, role }: { userEmail: string; role: Role })
|
|||
)}
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">Menu</DialogPrimitive.Title>
|
||||
<SideNavContent collapsed={false} userEmail={userEmail} role={role} themeMenu={{ side: "bottom", align: "end" }} />
|
||||
<SideNavContent
|
||||
collapsed={false}
|
||||
userEmail={userEmail}
|
||||
userName={userName}
|
||||
avatar={avatar}
|
||||
role={role}
|
||||
themeMenu={{ side: "bottom", align: "end" }}
|
||||
/>
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function SideNav({ userEmail, role }: { userEmail: string; role: Role }) {
|
||||
export function SideNav({
|
||||
userEmail,
|
||||
userName,
|
||||
avatar,
|
||||
role,
|
||||
}: {
|
||||
userEmail: string;
|
||||
userName: string | null;
|
||||
avatar: string | null;
|
||||
role: Role;
|
||||
}) {
|
||||
const { collapsed, toggle } = useSideNav();
|
||||
|
||||
return (
|
||||
|
|
@ -140,7 +238,14 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
|
|||
collapsed ? "w-16" : "w-60"
|
||||
)}
|
||||
>
|
||||
<SideNavContent collapsed={collapsed} userEmail={userEmail} role={role} themeMenu={{ side: "right", align: "start" }} />
|
||||
<SideNavContent
|
||||
collapsed={collapsed}
|
||||
userEmail={userEmail}
|
||||
userName={userName}
|
||||
avatar={avatar}
|
||||
role={role}
|
||||
themeMenu={{ side: "right", align: "start" }}
|
||||
/>
|
||||
|
||||
<div className={cn("flex border-t px-2 py-1.5", collapsed ? "justify-center" : "justify-end")}>
|
||||
<Button variant="ghost" size="icon" onClick={toggle} aria-label="Toggle sidebar">
|
||||
|
|
@ -149,7 +254,7 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
|
|||
</div>
|
||||
</aside>
|
||||
|
||||
<MobileNavDrawer userEmail={userEmail} role={role} />
|
||||
<MobileNavDrawer userEmail={userEmail} userName={userName} avatar={avatar} role={role} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Camera, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
removeAvatar as removeAvatarAction,
|
||||
updateProfileNames,
|
||||
uploadAvatar as uploadAvatarAction,
|
||||
} from "@/lib/actions/profile";
|
||||
import type { ProfileDTO } from "@/types/profile";
|
||||
|
||||
// Kept in sync with lib/actions/profile.ts (the server re-checks both).
|
||||
const MAX_AVATAR_BYTES = 10 * 1024 * 1024;
|
||||
const ACCEPTED_AVATAR_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
];
|
||||
|
||||
export function ProfileForm({ initial }: { initial: ProfileDTO }) {
|
||||
const [firstName, setFirstName] = useState(initial.firstName ?? "");
|
||||
const [lastName, setLastName] = useState(initial.lastName ?? "");
|
||||
// What's currently saved in the DB -- the "Save" button is only enabled
|
||||
// while the inputs differ from this.
|
||||
const [savedNames, setSavedNames] = useState({
|
||||
firstName: initial.firstName ?? "",
|
||||
lastName: initial.lastName ?? "",
|
||||
});
|
||||
const [avatar, setAvatar] = useState<string | null>(initial.avatar);
|
||||
const [savingNames, setSavingNames] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const namesDirty =
|
||||
firstName.trim() !== savedNames.firstName ||
|
||||
lastName.trim() !== savedNames.lastName;
|
||||
|
||||
async function handleSaveNames() {
|
||||
setSavingNames(true);
|
||||
try {
|
||||
await updateProfileNames(firstName, lastName);
|
||||
setSavedNames({ firstName, lastName });
|
||||
toast.success("Name saved.");
|
||||
} catch {
|
||||
toast.error("Couldn't save your name. Try again.");
|
||||
} finally {
|
||||
setSavingNames(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileChosen(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = ""; // allow re-picking the same file
|
||||
if (!file) return;
|
||||
|
||||
// Same rules as the server action -- check early so a bad file never
|
||||
// leaves the browser.
|
||||
if (!ACCEPTED_AVATAR_TYPES.includes(file.type)) {
|
||||
toast.error("Please choose a JPEG, PNG, WebP, or GIF image.");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_AVATAR_BYTES) {
|
||||
toast.error("Image must be 10 MB or smaller.");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await uploadAvatarAction(file);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
setAvatar(result.avatar ?? null);
|
||||
toast.success("Profile photo updated.");
|
||||
} catch (error) {
|
||||
// A thrown (rather than returned) error is a framework-level
|
||||
// rejection -- typically Next.js' server-action body limit 413ing
|
||||
// the file before it reaches the action. Say so instead of the
|
||||
// generic "try another image", which is misleading here.
|
||||
if (error instanceof Error && /body exceeded|413/i.test(error.message)) {
|
||||
toast.error(
|
||||
"Image is too large for the server to accept. Try one under 10 MB."
|
||||
);
|
||||
} else {
|
||||
toast.error("Couldn't upload that photo. Try again.");
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveAvatar() {
|
||||
setUploading(true);
|
||||
try {
|
||||
await removeAvatarAction();
|
||||
setAvatar(null);
|
||||
toast.success("Profile photo removed.");
|
||||
} catch {
|
||||
toast.error("Couldn't remove the photo. Try again.");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const initials = (firstName.trim()[0] ?? lastName.trim()[0] ?? "?").toUpperCase();
|
||||
|
||||
return (
|
||||
<Card className="max-w-xl">
|
||||
<CardContent className="space-y-5">
|
||||
{/* Photo */}
|
||||
<div className="flex items-center gap-4">
|
||||
{avatar ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- data-URL avatar, no image-optimization pipeline in this self-hosted app
|
||||
<img
|
||||
src={avatar}
|
||||
alt="Profile"
|
||||
className="size-16 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex size-16 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xl font-bold text-primary">
|
||||
{initials}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_AVATAR_TYPES.join(",")}
|
||||
className="hidden"
|
||||
onChange={handleFileChosen}
|
||||
disabled={uploading}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Camera className="size-3.5" />
|
||||
)}
|
||||
{uploading ? "Updating…" : avatar ? "Change photo" : "Add photo"}
|
||||
</Button>
|
||||
{avatar && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRemoveAvatar}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-first-name">First name</Label>
|
||||
<Input
|
||||
id="profile-first-name"
|
||||
value={firstName}
|
||||
maxLength={60}
|
||||
autoComplete="given-name"
|
||||
placeholder="Alex"
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && namesDirty && !savingNames && handleSaveNames()}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-last-name">Last name</Label>
|
||||
<Input
|
||||
id="profile-last-name"
|
||||
value={lastName}
|
||||
maxLength={60}
|
||||
autoComplete="family-name"
|
||||
placeholder="Fertig"
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && namesDirty && !savingNames && handleSaveNames()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
<Button onClick={handleSaveNames} disabled={!namesDirty || savingNames}>
|
||||
{savingNames ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Saving…
|
||||
</>
|
||||
) : (
|
||||
"Save name"
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,11 @@ services:
|
|||
volumes:
|
||||
# Local filesystem bind mount, not a named Docker volume.
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
ports:
|
||||
# Exposed on the host so host-side tooling (prisma migrate/dev, npm run
|
||||
# dev with the .env's localhost DATABASE_URL) can reach it. The app
|
||||
# container still connects over the compose network's "db" hostname.
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
"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 {};
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const NamePartSchema = z.string().trim().max(60, "Must be 60 characters or fewer");
|
||||
|
||||
export const ProfileNameSchema = z.object({
|
||||
firstName: NamePartSchema,
|
||||
lastName: NamePartSchema,
|
||||
});
|
||||
export type ProfileName = z.infer<typeof ProfileNameSchema>;
|
||||
|
|
@ -7,6 +7,15 @@ const nextConfig: NextConfig = {
|
|||
// container start, not imported by app code). The Dockerfile instead
|
||||
// copies the full `node_modules` into the runtime image, which is a
|
||||
// simpler and more reliable trade for a single self-hosted instance.
|
||||
experimental: {
|
||||
serverActions: {
|
||||
// Default is 1 MB, which silently 413s profile-photo uploads larger
|
||||
// than that before the action even runs (the client only sees a
|
||||
// generic failure). Allow the profile action's 10 MB max file plus
|
||||
// room for multipart overhead; other actions send tiny bodies.
|
||||
bodySizeLimit: "11mb",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "avatar" TEXT,
|
||||
ADD COLUMN "firstName" TEXT,
|
||||
ADD COLUMN "lastName" TEXT;
|
||||
|
||||
-- Backfill: existing accounts only have the single `name` captured at
|
||||
-- sign-up. Split it into firstName (first word) and lastName (the rest) so
|
||||
-- the Profile page starts from what the user already gave us instead of a
|
||||
-- blank form. Single-word names (e.g. "Cher") get firstName only.
|
||||
UPDATE "User"
|
||||
SET "firstName" = split_part("name", ' ', 1)
|
||||
WHERE "name" IS NOT NULL AND btrim("name") <> '';
|
||||
|
||||
UPDATE "User"
|
||||
SET "lastName" = trim(regexp_replace("name", '^\S+\s+', ''))
|
||||
WHERE "name" IS NOT NULL AND "name" ~ '^\S+\s+\S+';
|
||||
|
|
@ -34,7 +34,18 @@ model User {
|
|||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
// Legacy single name field, captured at sign-up. Display and the Admin
|
||||
// page still use it; the Profile page manages firstName/lastName below.
|
||||
name String?
|
||||
// Profile page fields. Null = the user hasn't set them.
|
||||
firstName String?
|
||||
lastName String?
|
||||
// Profile photo, stored as a data URL (base64 JPEG) rather than a file on
|
||||
// disk: this app's container filesystem is ephemeral (only the Postgres
|
||||
// volume persists across deploys), so the DB is the one place it can live.
|
||||
// Always server-generated from the user's upload (resized to 256x256 via
|
||||
// sharp in lib/actions/profile.ts), never stored as the raw upload.
|
||||
avatar String?
|
||||
// The very first person to sign up becomes ADMIN regardless of
|
||||
// signupMode (the site needs at least one admin to bootstrap). Everyone
|
||||
// after that gets USER (signupMode OPEN) or PENDING (APPROVED/CONFIRMED),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ async function main() {
|
|||
email: "dev@example.com",
|
||||
passwordHash,
|
||||
name: "Dev User",
|
||||
// Mirrors the migration's backfill of the legacy `name` into the
|
||||
// profile fields (first word / rest) so seeded accounts look the
|
||||
// same as ones that predate the Profile page.
|
||||
firstName: "Dev",
|
||||
lastName: "User",
|
||||
role: Role.ADMIN,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
/** The signed-in user's profile as rendered by the Profile page and the
|
||||
* sidebar -- nulls mean "not set". */
|
||||
export interface ProfileDTO {
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
avatar: string | null;
|
||||
}
|
||||
Loading…
Reference in New Issue