Add user profile page with name and photo support
- New `/profile` page lets users set first/last name and upload a profile photo (resized to 256x256 JPEG server-side via sharp, stored as a data URL in Postgres since the container FS is ephemeral) - Sidebar user block now shows the profile photo (or initial-letter fallback) and display name, and links to `/profile` - Migration adds `firstName`, `lastName`, `avatar` columns to User and backfills them from the legacy `name` field; seed updated to match - Bump Next.js server-action body limit to 11 MB so avatar uploads up to the 10 MB cap aren't silently rejected with a 413 - Expose Postgres port on the host in docker-compose for local tooling
This commit is contained in:
parent
25f5c4240e
commit
83075c8791
|
|
@ -26,6 +26,9 @@ given instance automatically becomes its administrator.
|
||||||
password, or delete their account
|
password, or delete their account
|
||||||
- control how the site handles new sign-ups (see [Sign-up modes](#sign-up-modes)
|
- control how the site handles new sign-ups (see [Sign-up modes](#sign-up-modes)
|
||||||
below)
|
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
|
## 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.
|
// board (categories/groups/todos) is fetched separately by its own page.
|
||||||
// `theme` is stored as free text; the set of valid names is enforced
|
// `theme` is stored as free text; the set of valid names is enforced
|
||||||
// client-side (ProjectThemeSchema) so a plain assertion is safe here.
|
// client-side (ProjectThemeSchema) so a plain assertion is safe here.
|
||||||
const projects: ProjectDTO[] = (
|
const [projects, profile] = await Promise.all([
|
||||||
await prisma.project.findMany({
|
prisma.project.findMany({
|
||||||
where: { ownerId: session.user.id },
|
where: { ownerId: session.user.id },
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
select: { id: true, title: true, theme: true },
|
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,
|
id: p.id,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
theme: p.theme as ThemeName | null,
|
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 (
|
return (
|
||||||
<SideNavProvider>
|
<SideNavProvider>
|
||||||
<ScheduledPanelProvider>
|
<ScheduledPanelProvider>
|
||||||
<ProjectsProvider initialProjects={projects}>
|
<ProjectsProvider initialProjects={projectList}>
|
||||||
<BoardViewProvider>
|
<BoardViewProvider>
|
||||||
<HoldOnCompleteProvider>
|
<HoldOnCompleteProvider>
|
||||||
{/* Responsive shell:
|
{/* Responsive shell:
|
||||||
|
|
@ -51,7 +65,12 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||||
toolbar instead of the stale 100vh. */}
|
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">
|
<div className="flex h-dvh flex-col md:h-screen md:min-w-[860px] md:flex-row md:overflow-x-auto">
|
||||||
<MobileTopBar />
|
<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>
|
<main className="min-h-0 flex-1 overflow-auto">{children}</main>
|
||||||
<ScheduledPanel />
|
<ScheduledPanel />
|
||||||
</div>
|
</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";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { ChevronLeft, ChevronRight, LogOut, NotebookPen } from "lucide-react";
|
import { ChevronLeft, ChevronRight, LogOut, NotebookPen } from "lucide-react";
|
||||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
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 { logout } from "@/lib/actions/auth";
|
||||||
import { Role } from "@/lib/generated/prisma/enums";
|
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
|
* The actual nav content, shared by the desktop aside and the mobile
|
||||||
* hamburger drawer so the two never drift apart. `collapsed` is always
|
* hamburger drawer so the two never drift apart. `collapsed` is always
|
||||||
|
|
@ -22,11 +59,15 @@ import { Role } from "@/lib/generated/prisma/enums";
|
||||||
function SideNavContent({
|
function SideNavContent({
|
||||||
collapsed,
|
collapsed,
|
||||||
userEmail,
|
userEmail,
|
||||||
|
userName,
|
||||||
|
avatar,
|
||||||
role,
|
role,
|
||||||
themeMenu,
|
themeMenu,
|
||||||
}: {
|
}: {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
userEmail: string;
|
userEmail: string;
|
||||||
|
userName: string | null;
|
||||||
|
avatar: string | null;
|
||||||
role: Role;
|
role: Role;
|
||||||
// Where the theme picker menu opens from its trigger -- right of the
|
// Where the theme picker menu opens from its trigger -- right of the
|
||||||
// trigger in the desktop sidebar, below it in the mobile drawer (a
|
// 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" };
|
themeMenu: { side: "right" | "bottom"; align: "start" | "end" };
|
||||||
}) {
|
}) {
|
||||||
const adminItem = role === Role.ADMIN ? [ADMIN_NAV_ITEM] : [];
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -85,11 +129,38 @@ function SideNavContent({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn("flex items-center gap-2 border-t px-4 py-3", collapsed && "justify-center px-0")}>
|
<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">
|
{collapsed ? (
|
||||||
{(userEmail[0] ?? "?").slice(0, 1)}
|
<Tooltip>
|
||||||
</span>
|
<TooltipTrigger
|
||||||
{!collapsed && (
|
render={
|
||||||
<span className="truncate text-[13px] font-medium text-muted-foreground">{userEmail}</span>
|
<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>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -100,7 +171,17 @@ function SideNavContent({
|
||||||
* Mobile (< md) hamburger drawer: the full side nav in a left sheet.
|
* Mobile (< md) hamburger drawer: the full side nav in a left sheet.
|
||||||
* Tapping any link closes it; the backdrop and Escape close it too.
|
* 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();
|
const { mobileOpen, setMobileOpen } = useSideNav();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -122,14 +203,31 @@ function MobileNavDrawer({ userEmail, role }: { userEmail: string; role: Role })
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DialogPrimitive.Title className="sr-only">Menu</DialogPrimitive.Title>
|
<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.Popup>
|
||||||
</DialogPrimitive.Portal>
|
</DialogPrimitive.Portal>
|
||||||
</DialogPrimitive.Root>
|
</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();
|
const { collapsed, toggle } = useSideNav();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -140,7 +238,14 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
|
||||||
collapsed ? "w-16" : "w-60"
|
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")}>
|
<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">
|
<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>
|
</div>
|
||||||
</aside>
|
</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:
|
volumes:
|
||||||
# Local filesystem bind mount, not a named Docker volume.
|
# Local filesystem bind mount, not a named Docker volume.
|
||||||
- ./data/postgres:/var/lib/postgresql/data
|
- ./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:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
interval: 5s
|
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
|
// container start, not imported by app code). The Dockerfile instead
|
||||||
// copies the full `node_modules` into the runtime image, which is a
|
// copies the full `node_modules` into the runtime image, which is a
|
||||||
// simpler and more reliable trade for a single self-hosted instance.
|
// 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;
|
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())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
passwordHash String
|
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?
|
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
|
// The very first person to sign up becomes ADMIN regardless of
|
||||||
// signupMode (the site needs at least one admin to bootstrap). Everyone
|
// signupMode (the site needs at least one admin to bootstrap). Everyone
|
||||||
// after that gets USER (signupMode OPEN) or PENDING (APPROVED/CONFIRMED),
|
// after that gets USER (signupMode OPEN) or PENDING (APPROVED/CONFIRMED),
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ async function main() {
|
||||||
email: "dev@example.com",
|
email: "dev@example.com",
|
||||||
passwordHash,
|
passwordHash,
|
||||||
name: "Dev User",
|
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,
|
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