src/components/shell.tsx
Rama main · réplica de lectura del repositorio original.
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { motion } from "framer-motion";
import {
ArrowUpRightIcon,
BookOpenIcon,
CircleUserRoundIcon,
Building2Icon,
ChevronDownIcon,
KanbanIcon,
ListChecksIcon,
Trash2Icon,
LogOutIcon,
MenuIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
SearchIcon,
SparklesIcon,
SettingsIcon,
ShieldIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { CommandPalette } from "@/components/command-palette";
import { SHORTCUTS, useKeyboardNav } from "@/hooks/use-keyboard-nav";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { HiredMark } from "@/components/hired-mark";
import { Notifications, type Notice } from "@/components/notifications";
import { AssistantDrawer } from "@/components/assistant/assistant-drawer";
import { AskContext } from "@/components/assistant/ask";
import { UserAvatar } from "@/components/user-avatar";
import { logoutAction } from "@/server/actions";
import { MANUAL_URL } from "@/lib/links";
// Navigation only. Settings and Admin are account actions, so they live in the
// profile menu at the top right rather than in the rail.
//
// The words here are the ones the welcome tour teaches, and they were not.
// The tour's second card says "every job you apply to is a card, drag it along"
// — and then the rail read "Pipeline", a word the tour never uses, next to
// "CRM", a three-letter acronym this audience has never met. Labels only: the
// routes stay /applications and /crm, so every link, saved view and bookmark
// still works, and the command palette carries the old words as search aliases
// for anyone who does know them.
//
// CRM is the one entry with children: it is two peer screens (companies and
// people), and reaching the second one used to require landing on the first
// and finding the tabs. The rail names both — but folded away until asked for,
// because a permanently open branch makes a five-item rail read as seven and
// buries Pipeline. The parent is still a link to /crm; the chevron beside it is
// what opens the branch. In the collapsed rail the children fold into the
// icon's tooltip-covered single link, and the tabs on the page take over.
type NavItem = {
href: string;
label: string;
icon: typeof KanbanIcon;
children?: { href: string; label: string }[];
};
const NAV: NavItem[] = [
{ href: "/", label: "Today", icon: ListChecksIcon },
{ href: "/me", label: "Me", icon: CircleUserRoundIcon },
{
href: "/crm",
label: "People",
icon: Building2Icon,
children: [
{ href: "/crm/companies", label: "Companies" },
{ href: "/crm/contacts", label: "Contacts" },
],
},
{ href: "/applications", label: "Board", icon: KanbanIcon },
];
// The rail remembers whether you collapsed it. Read after mount so the server
// and the first client render agree.
const COLLAPSE_KEY = "hired:sidebar-collapsed";
/** Which nav branches are open. Same reasoning as the rail's own collapse. */
const BRANCH_KEY = "hired:nav-open-branches";
export type ShellUser = { name: string; email: string; role: string; photo: string };
export function Shell({
children,
notices,
user,
assistant,
}: {
children: React.ReactNode;
notices: Notice[];
user: ShellUser;
/**
* Whether an admin has put an API key in. False is the default and means the
* button below is never rendered — an instance whose people connect their own
* client should look exactly as it did before this feature landed, and a
* disabled control that explains itself is still a control they did not want.
*/
assistant: boolean;
}) {
const canAdmin = user.role === "ADMIN" || user.role === "SUPER_ADMIN";
const pathname = usePathname();
const [paletteOpen, setPaletteOpen] = useState(false);
// j/k through a list, / to search, n to create, ? for the sheet. One
// implementation for every screen: a row opts in by tagging its link.
const { showHelp, setShowHelp } = useKeyboardNav();
const [drawerOpen, setDrawerOpen] = useState(false);
const [askOpen, setAskOpen] = useState(false);
// A request another screen handed over ("mine this role"), waiting in the
// drawer's box for them to read and send.
const [askSeed, setAskSeed] = useState<string | null>(null);
const ask = useCallback((message: string) => {
setAskSeed(message);
setAskOpen(true);
}, []);
const [collapsed, setCollapsed] = useState(false);
const [openBranches, setOpenBranches] = useState<string[]>([]);
// Navigating is the reason the drawer was opened, so arriving closes it.
useEffect(() => {
setDrawerOpen(false);
}, [pathname]);
useEffect(() => {
setCollapsed(window.localStorage.getItem(COLLAPSE_KEY) === "1");
try {
const stored = JSON.parse(window.localStorage.getItem(BRANCH_KEY) ?? "[]");
if (Array.isArray(stored)) setOpenBranches(stored.filter((v) => typeof v === "string"));
} catch {
// A corrupt value is not worth a broken sidebar; start closed.
}
}, []);
const toggleBranch = (href: string) => {
setOpenBranches((current) => {
const next = current.includes(href)
? current.filter((value) => value !== href)
: [...current, href];
window.localStorage.setItem(BRANCH_KEY, JSON.stringify(next));
return next;
});
};
/**
* Being inside a branch opens it, whatever was stored. Landing on
* /crm/contacts from a link and finding the rail insisting Contacts is
* hidden would be the rail arguing with the page.
*/
const branchOpen = (href: string) =>
openBranches.includes(href) || pathname.startsWith(href);
const toggleCollapsed = () => {
setCollapsed((value) => {
const next = !value;
window.localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0");
return next;
});
};
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
setPaletteOpen((open) => !open);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// A resume is opened from Me, so the editor lights Me up rather than nothing.
const isActive = (href: string) =>
href === "/"
? pathname === "/"
: pathname.startsWith(href) || (href === "/me" && pathname.startsWith("/resumes"));
return (
<AskContext.Provider value={assistant ? ask : null}>
<div className="flex min-h-svh">
{/* Rail */}
<aside
className={cn(
"bg-sidebar sticky top-0 z-30 hidden h-svh shrink-0 flex-col border-r transition-[width] duration-200 md:flex",
collapsed ? "w-[4.5rem]" : "w-[15rem]",
)}
>
<div
className={cn(
"flex h-16 items-center gap-2.5",
collapsed ? "justify-center px-2" : "px-5",
)}
>
{collapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={toggleCollapsed}
aria-label="Expand sidebar"
className="hover:bg-accent group relative flex size-9 items-center justify-center rounded-lg transition-colors"
>
<HiredMark size={26} className="transition-opacity group-hover:opacity-0" />
<PanelLeftOpenIcon className="text-muted-foreground absolute size-4 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
</TooltipTrigger>
<TooltipContent side="right">Expand sidebar</TooltipContent>
</Tooltip>
) : (
<>
<HiredMark size={26} className="shrink-0" />
<div className="min-w-0 leading-tight">
<div className="truncate text-[15px] font-semibold tracking-tight">Hired</div>
{/* 10.5px, not 11px: the line is five pixels too wide for this
rail at 11 and ellipsises to "on the rec…", and a clipped
tagline is worse than a slightly smaller one. */}
<div className="text-muted-foreground truncate text-[10.5px] tracking-[-0.004em]">
Your career, on the record
</div>
</div>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground -mr-1.5 ml-auto"
onClick={toggleCollapsed}
aria-label="Collapse sidebar"
>
<PanelLeftCloseIcon />
</Button>
</>
)}
</div>
<div className={cn("pb-2", collapsed ? "px-2" : "px-3")}>
{collapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setPaletteOpen(true)}
className="text-muted-foreground hover:text-foreground hover:bg-accent bg-card flex w-full items-center justify-center rounded-md border py-2 transition-colors"
aria-label="Search"
>
<SearchIcon className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">Search · ⌘K</TooltipContent>
</Tooltip>
) : (
<button
onClick={() => setPaletteOpen(true)}
className="text-muted-foreground hover:text-foreground hover:bg-accent bg-card flex w-full items-center gap-2 rounded-md border px-3 py-1.5 text-[13px] transition-colors"
>
<SearchIcon className="size-3.5" />
<span>Search…</span>
<kbd className="bg-muted text-muted-foreground ml-auto rounded px-1.5 py-0.5 font-mono text-[10px]">
⌘K
</kbd>
</button>
)}
</div>
<nav className={cn("flex flex-1 flex-col gap-0.5", collapsed ? "px-2" : "px-3")}>
{NAV.map((item) => {
const active = isActive(item.href);
const link = (
<Link
href={item.href}
aria-label={item.label}
className={cn(
"group relative flex items-center rounded-lg py-2 text-sm font-medium transition-colors",
collapsed ? "justify-center px-2" : "gap-3 px-3",
active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{active && (
<motion.span
layoutId="nav-active"
className="bg-accent absolute inset-0 rounded-lg"
transition={{ type: "spring", stiffness: 380, damping: 32 }}
/>
)}
<item.icon
className={cn(
"relative size-4 shrink-0 transition-colors",
active ? "text-primary" : "group-hover:text-foreground",
)}
/>
{!collapsed && <span className="relative">{item.label}</span>}
</Link>
);
if (collapsed) {
return (
<Tooltip key={item.href}>
<TooltipTrigger asChild>{link}</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
);
}
if (!item.children) return <div key={item.href}>{link}</div>;
const open = branchOpen(item.href);
const branchId = `nav-branch-${item.href.replace(/\W/g, "")}`;
return (
<div key={item.href} className="flex flex-col gap-0.5">
{/* The chevron sits over the link rather than inside it: a
button nested in an anchor is invalid, and the parent has to
stay a real link — clicking CRM should go to CRM, not just
unfold it. */}
<div className="relative">
{link}
<button
type="button"
onClick={() => toggleBranch(item.href)}
aria-expanded={open}
aria-controls={branchId}
aria-label={`${open ? "Hide" : "Show"} ${item.label} sections`}
className="text-faint hover:bg-accent hover:text-foreground absolute inset-y-1 right-1 flex w-7 items-center justify-center rounded-md transition-colors"
>
<ChevronDownIcon
className={cn(
"size-3.5 transition-transform duration-200",
open && "rotate-180",
)}
/>
</button>
</div>
<div id={branchId} hidden={!open} className="flex flex-col gap-0.5">
{item.children.map((child) => {
const childActive = pathname.startsWith(child.href);
return (
<Link
key={child.href}
href={child.href}
aria-current={childActive ? "page" : undefined}
className={cn(
// Indented to sit under the parent's label, not its icon,
// so the hierarchy reads at a glance.
"ml-[2.4rem] flex items-center rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors",
childActive
? "bg-accent/70 text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{child.label}
</Link>
);
})}
</div>
</div>
);
})}
</nav>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
{/* Top bar */}
<header className="glass sticky top-0 z-20 flex h-16 items-center gap-2 border-b px-4 md:px-7">
<div className="flex min-w-0 items-center gap-2 md:hidden">
<Button
variant="ghost"
size="icon-sm"
className="size-9"
onClick={() => setDrawerOpen(true)}
aria-label="Open menu"
>
<MenuIcon />
</Button>
{/* Five unlabelled icons told you nothing about where you were.
The drawer holds the navigation; the bar just names the page. */}
<span className="truncate text-[15px] font-semibold tracking-tight">
{NAV.find((item) => isActive(item.href))?.label ?? "Hired"}
</span>
</div>
<div className="ml-auto flex items-center gap-1.5">
<Button
variant="ghost"
size="icon-sm"
className="size-9 md:hidden"
onClick={() => setPaletteOpen(true)}
aria-label="Search"
>
<SearchIcon />
</Button>
{/* One button, beside the bell. Not a nav item: this is something
you ask while looking at a screen, not a place you go. */}
{assistant && (
<Button
variant="ghost"
size="icon-sm"
className="size-9"
onClick={() => setAskOpen(true)}
aria-label="Ask the assistant"
>
<SparklesIcon />
</Button>
)}
<Notifications items={notices} />
<ProfileMenu user={user} canAdmin={canAdmin} />
</div>
</header>
<MobileNav
open={drawerOpen}
onOpenChange={setDrawerOpen}
isActive={isActive}
canAdmin={canAdmin}
branchOpen={branchOpen}
onToggleBranch={toggleBranch}
onSearch={() => {
setDrawerOpen(false);
setPaletteOpen(true);
}}
/>
<main className="min-w-0 flex-1">{children}</main>
</div>
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
{assistant && (
<AssistantDrawer
open={askOpen}
onOpenChange={setAskOpen}
seed={askSeed}
onSeedTaken={() => setAskSeed(null)}
/>
)}
<Dialog open={showHelp} onOpenChange={setShowHelp}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Keyboard</DialogTitle>
<DialogDescription className="sr-only">
The keys this app answers to.
</DialogDescription>
</DialogHeader>
<ul className="space-y-2">
{SHORTCUTS.map((shortcut) => (
<li key={shortcut.keys} className="flex items-center gap-3 text-[13px]">
<kbd className="bg-inset rounded-control min-w-14 px-1.5 py-0.5 text-center font-mono text-[11.5px]">
{shortcut.keys}
</kbd>
<span className="text-muted-foreground">{shortcut.what}</span>
</li>
))}
</ul>
</DialogContent>
</Dialog>
</div>
</AskContext.Provider>
);
}
/**
* The phone's navigation. Everything the desktop rail holds, plus the account
* links that live in the profile menu — because on a phone the profile menu is
* a 28px avatar and "where is Settings" should not depend on finding it.
*
* Every link closes it on tap. The pathname effect above is still the backstop,
* but it only fires once the server has answered — so on a slow connection a
* tap looked like nothing had happened, with the menu sitting on top of the
* screen it was navigating to.
*/
function MobileNav({
open,
onOpenChange,
isActive,
canAdmin,
onSearch,
branchOpen,
onToggleBranch,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
isActive: (href: string) => boolean;
canAdmin: boolean;
onSearch: () => void;
/** Shared with the rail so the drawer and the sidebar never disagree. */
branchOpen: (href: string) => boolean;
onToggleBranch: (href: string) => void;
}) {
// One Docs, and it is the manual. The in-app page that used to sit beside it
// said the same things from the same tools array, and the menu should not ask
// anybody to pick between two answers to one question. It lives on another
// origin, so it opens in its own tab and the arrow says so.
const secondary = [
{ href: "/archive", label: "Archive", icon: Trash2Icon, external: false },
{ href: MANUAL_URL, label: "Docs", icon: BookOpenIcon, external: true },
{ href: "/settings", label: "Settings", icon: SettingsIcon, external: false },
...(canAdmin
? [{ href: "/settings/admin", label: "Admin", icon: ShieldIcon, external: false }]
: []),
];
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" showClose={false} className="gap-0 p-0 md:hidden">
<SheetTitle className="sr-only">Menu</SheetTitle>
<div className="flex h-16 items-center gap-2.5 px-5">
<HiredMark size={26} className="shrink-0" />
<div className="min-w-0 leading-tight">
<div className="truncate text-[15px] font-semibold tracking-tight">Hired</div>
<div className="text-muted-foreground truncate text-[10.5px] tracking-[-0.004em]">
Your career, on the record
</div>
</div>
</div>
<div className="px-3 pb-2">
<button
onClick={onSearch}
className="text-muted-foreground hover:text-foreground hover:bg-accent bg-card flex h-11 w-full items-center gap-2 rounded-md border px-3 text-[14px] transition-colors"
>
<SearchIcon className="size-4" />
<span>Search…</span>
</button>
</div>
<nav className="flex flex-col gap-0.5 px-3">
{NAV.map((item) => {
const active = isActive(item.href);
const open = item.children ? branchOpen(item.href) : false;
const branchId = `drawer-branch-${item.href.replace(/\W/g, "")}`;
return (
<div key={item.href} className="flex flex-col gap-0.5">
<div className="relative">
<Link
href={item.href}
onClick={() => onOpenChange(false)}
className={cn(
"flex h-11 items-center gap-3 rounded-lg px-3 text-[14px] font-medium transition-colors",
active ? "bg-accent text-foreground" : "text-muted-foreground",
item.children && "pr-12",
)}
>
<item.icon className={cn("size-4 shrink-0", active && "text-primary")} />
<span>{item.label}</span>
</Link>
{item.children && (
<button
type="button"
onClick={() => onToggleBranch(item.href)}
aria-expanded={open}
aria-controls={branchId}
aria-label={`${open ? "Hide" : "Show"} ${item.label} sections`}
className="text-faint hover:text-foreground absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-lg transition-colors"
>
<ChevronDownIcon
className={cn(
"size-4 transition-transform duration-200",
open && "rotate-180",
)}
/>
</button>
)}
</div>
{item.children && (
<div id={branchId} hidden={!open} className="flex flex-col gap-0.5">
{item.children.map((child) => (
<Link
key={child.href}
href={child.href}
onClick={() => onOpenChange(false)}
className="text-muted-foreground ml-10 flex h-9 items-center rounded-lg px-3 text-[13px] font-medium transition-colors"
>
{child.label}
</Link>
))}
</div>
)}
</div>
);
})}
</nav>
<div className="mt-auto flex flex-col gap-0.5 border-t px-3 py-3">
{secondary.map((item) => {
const className =
"text-muted-foreground flex h-11 items-center gap-3 rounded-lg px-3 text-[14px] font-medium transition-colors";
const body = (
<>
<item.icon className="size-4 shrink-0" />
<span>{item.label}</span>
{item.external && <ArrowUpRightIcon className="ml-auto size-3.5 shrink-0" />}
</>
);
return item.external ? (
<a
key={item.href}
href={item.href}
target="_blank"
rel="noreferrer"
className={className}
>
{body}
</a>
) : (
<Link
key={item.href}
href={item.href}
onClick={() => onOpenChange(false)}
className={className}
>
{body}
</Link>
);
})}
<form action={logoutAction}>
<button
type="submit"
className="text-muted-foreground flex h-11 w-full items-center gap-3 rounded-lg px-3 text-[14px] font-medium transition-colors"
>
<LogOutIcon className="size-4 shrink-0" />
<span>Sign out</span>
</button>
</form>
</div>
</SheetContent>
</Sheet>
);
}
function ProfileMenu({ user, canAdmin }: { user: ShellUser; canAdmin: boolean }) {
const roleLabel = user.role === "SUPER_ADMIN" ? "Owner" : canAdmin ? "Admin" : "Member";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="hover:bg-accent touch-target flex items-center gap-2 rounded-full border bg-card py-1 pl-1 pr-2.5 transition-colors"
aria-label="Account menu"
>
<UserAvatar name={user.name} email={user.email} photo={user.photo} size={28} />
<span className="hidden max-w-[10rem] truncate text-[13px] font-medium sm:block">
{user.name || user.email}
</span>
<ChevronDownIcon className="text-muted-foreground size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<div className="flex items-center gap-2.5 px-2 py-1.5">
<UserAvatar name={user.name} email={user.email} photo={user.photo} size={32} />
<div className="min-w-0">
<div className="truncate text-[13px] font-medium">{user.name || user.email}</div>
<div className="text-muted-foreground truncate text-[11px]">{user.email}</div>
</div>
</div>
<div className="text-muted-foreground px-2 pb-1.5 text-[11px]">{roleLabel}</div>
<DropdownMenuSeparator />
{/* Docs is docs.hired.tools. It is on another origin, hence the arrow
and the tab; the skills it used to carry are on Settings, because
those files are served by this instance and nothing else can. */}
<DropdownMenuItem asChild>
<Link href="/archive">
<Trash2Icon /> Archive
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={MANUAL_URL} target="_blank" rel="noreferrer">
<BookOpenIcon /> Docs
<ArrowUpRightIcon className="text-muted-foreground ml-auto size-3.5" />
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/settings">
<SettingsIcon /> Settings
</Link>
</DropdownMenuItem>
{canAdmin && (
<DropdownMenuItem asChild>
<Link href="/settings/admin">
<ShieldIcon /> Admin
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<form action={logoutAction}>
<DropdownMenuItem asChild>
<button type="submit" className="w-full">
<LogOutIcon /> Sign out
</button>
</DropdownMenuItem>
</form>
</DropdownMenuContent>
</DropdownMenu>
);
}