261 lines
8.4 KiB
TypeScript
261 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { ChevronLeft, ChevronRight, LogOut, NotebookPen } from "lucide-react";
|
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { useSideNav } from "@/components/nav/side-nav-provider";
|
|
import { SideNavItem } from "@/components/nav/side-nav-item";
|
|
import { ProjectsNavSection } from "@/components/nav/projects-nav-section";
|
|
import { NAV_ITEMS, ADMIN_NAV_ITEM } from "@/components/nav/nav-items";
|
|
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
|
|
* false in the drawer (a 288px-wide sheet has no room for icon-only mode).
|
|
*/
|
|
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
|
|
// right-opening menu would run off the phone's right edge there).
|
|
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 (
|
|
<>
|
|
<div className={cn("flex items-center gap-2.5 px-4 py-4", collapsed && "justify-center px-0")}>
|
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm">
|
|
<NotebookPen className="size-4.5" />
|
|
</span>
|
|
{!collapsed && (
|
|
<span className="truncate font-heading text-[17px] font-bold tracking-tight">
|
|
Organize
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-2.5 pt-1 pb-2">
|
|
{NAV_ITEMS.map((item) => (
|
|
<SideNavItem key={item.href} item={item} collapsed={collapsed} />
|
|
))}
|
|
<ProjectsNavSection collapsed={collapsed} />
|
|
{adminItem.map((item) => (
|
|
<SideNavItem key={item.href} item={item} collapsed={collapsed} />
|
|
))}
|
|
</nav>
|
|
|
|
<div className="space-y-0.5 px-2.5 pb-2">
|
|
<ThemeToggle collapsed={collapsed} menuSide={themeMenu.side} menuAlign={themeMenu.align} />
|
|
|
|
{collapsed ? (
|
|
<form action={logout}>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<Button variant="ghost" size="icon" type="submit" className="w-full">
|
|
<LogOut className="size-4" />
|
|
</Button>
|
|
}
|
|
/>
|
|
<TooltipContent side="right">Log out ({userEmail})</TooltipContent>
|
|
</Tooltip>
|
|
</form>
|
|
) : (
|
|
<form action={logout}>
|
|
<Button variant="ghost" type="submit" className="w-full justify-start gap-2.5">
|
|
<LogOut className="size-4" />
|
|
<span className="truncate">Log out</span>
|
|
</Button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
|
|
<div className={cn("flex items-center gap-2 border-t px-4 py-3", collapsed && "justify-center px-0")}>
|
|
{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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
userName,
|
|
avatar,
|
|
role,
|
|
}: {
|
|
userEmail: string;
|
|
userName: string | null;
|
|
avatar: string | null;
|
|
role: Role;
|
|
}) {
|
|
const { mobileOpen, setMobileOpen } = useSideNav();
|
|
|
|
return (
|
|
<DialogPrimitive.Root open={mobileOpen} onOpenChange={setMobileOpen}>
|
|
<DialogPrimitive.Portal>
|
|
<DialogPrimitive.Backdrop
|
|
className="fixed inset-0 z-40 bg-black/60 duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 md:hidden"
|
|
/>
|
|
<DialogPrimitive.Popup
|
|
onClick={(e) => {
|
|
// Close on navigation -- links (nav items, projects) are the only
|
|
// things that should dismiss it, not e.g. a theme switch.
|
|
if ((e.target as HTMLElement).closest("a")) setMobileOpen(false);
|
|
}}
|
|
className={cn(
|
|
"fixed inset-y-0 left-0 z-50 flex w-72 max-w-[85vw] flex-col overflow-y-auto bg-sidebar text-sidebar-foreground shadow-xl duration-200 md:hidden",
|
|
"data-open:animate-in data-open:fade-in-0 data-open:slide-in-from-left",
|
|
"data-closed:animate-out data-closed:fade-out-0 data-closed:slide-out-to-left"
|
|
)}
|
|
>
|
|
<DialogPrimitive.Title className="sr-only">Menu</DialogPrimitive.Title>
|
|
<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,
|
|
userName,
|
|
avatar,
|
|
role,
|
|
}: {
|
|
userEmail: string;
|
|
userName: string | null;
|
|
avatar: string | null;
|
|
role: Role;
|
|
}) {
|
|
const { collapsed, toggle } = useSideNav();
|
|
|
|
return (
|
|
<>
|
|
<aside
|
|
className={cn(
|
|
"hidden h-screen flex-col border-r bg-sidebar text-sidebar-foreground transition-[width] duration-300 ease-out md:flex",
|
|
collapsed ? "w-16" : "w-60"
|
|
)}
|
|
>
|
|
<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">
|
|
{collapsed ? <ChevronRight className="size-4" /> : <ChevronLeft className="size-4" />}
|
|
</Button>
|
|
</div>
|
|
</aside>
|
|
|
|
<MobileNavDrawer userEmail={userEmail} userName={userName} avatar={avatar} role={role} />
|
|
</>
|
|
);
|
|
}
|