324 lines
8.7 KiB
TypeScript
324 lines
8.7 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* SiteSidebar — desktop-only thin rail that expands on hover.
|
|
*
|
|
* Layout (collapsed, w-16):
|
|
*
|
|
* ┌──┐
|
|
* │🏠│ ← top group (Home / Search / Kautilya /
|
|
* │🔍│ Collections / Bookmarks / Mentions)
|
|
* │✦ │
|
|
* │📁│
|
|
* │🔖│
|
|
* │@ │
|
|
* │ │
|
|
* │⋮ │ ← bottom group (Reading settings, Profile)
|
|
* │👤│
|
|
* └──┘
|
|
*
|
|
* Hovering anywhere on the rail expands it to w-56 and reveals labels.
|
|
* The rail floats above content (fixed position, the main area is
|
|
* padded by w-16) so the expansion never causes layout shift.
|
|
*
|
|
* Hidden below md — mobile uses SiteBottomNav.
|
|
*/
|
|
|
|
import { useState } from "react";
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import {
|
|
AtSign,
|
|
Bookmark,
|
|
FolderClosed,
|
|
Home,
|
|
LogIn,
|
|
Search,
|
|
Settings2,
|
|
User as UserIcon,
|
|
} from "lucide-react";
|
|
import { useAuth } from "@/components/auth/auth-provider";
|
|
import { ReadingSettings } from "@/components/reading-settings";
|
|
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type NavItem = {
|
|
href: string;
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
/** Optional: routes to mark active when the path starts with these.
|
|
* Used for /books variants (lectures, letters, etc.) all flagging the
|
|
* Knowledgebase entry. */
|
|
matchPrefixes?: string[];
|
|
};
|
|
|
|
const TOP_ITEMS: NavItem[] = [
|
|
{ href: "/", label: "Home", icon: <Home className="size-5" /> },
|
|
{
|
|
href: "/search",
|
|
label: "Search",
|
|
icon: <Search className="size-5" />,
|
|
// Search doubles as the knowledgebase browse surface — light it up
|
|
// any time the user is deep inside the library so they have a clear
|
|
// path back.
|
|
matchPrefixes: [
|
|
"/search",
|
|
"/books",
|
|
"/letters",
|
|
"/transcriptions",
|
|
"/essays",
|
|
"/glossary",
|
|
"/journals",
|
|
"/magazines",
|
|
"/other-documents",
|
|
"/disciples",
|
|
],
|
|
},
|
|
{
|
|
href: "/chat",
|
|
label: "Kautilya",
|
|
icon: <KautilyaIcon className="size-5" />,
|
|
},
|
|
{
|
|
href: "/u/me/collections",
|
|
label: "Collections",
|
|
icon: <FolderClosed className="size-5" />,
|
|
},
|
|
{
|
|
href: "/u/me/bookmarks",
|
|
label: "Bookmarks",
|
|
icon: <Bookmark className="size-5" />,
|
|
},
|
|
{
|
|
href: "/u/me/mentions",
|
|
label: "Mentions",
|
|
icon: <AtSign className="size-5" />,
|
|
},
|
|
];
|
|
|
|
export function SiteSidebar() {
|
|
const pathname = usePathname();
|
|
const { user } = useAuth();
|
|
// While the reading-settings popover is open we pin the rail to its
|
|
// expanded width so the popover (which anchors to the trigger button)
|
|
// doesn't visually drift when the user's mouse leaves the rail.
|
|
const [readingOpen, setReadingOpen] = useState(false);
|
|
|
|
function isActive(item: NavItem): boolean {
|
|
if (item.matchPrefixes) {
|
|
return item.matchPrefixes.some((p) =>
|
|
p === "/" ? pathname === "/" : pathname.startsWith(p),
|
|
);
|
|
}
|
|
if (item.href === "/") return pathname === "/";
|
|
return pathname === item.href || pathname.startsWith(`${item.href}/`);
|
|
}
|
|
|
|
return (
|
|
<aside
|
|
aria-label="Primary navigation"
|
|
data-expanded={readingOpen ? "true" : undefined}
|
|
className={cn(
|
|
"hidden md:flex group fixed inset-y-0 left-0 z-40",
|
|
"transition-[width] duration-200 ease-out",
|
|
readingOpen ? "w-56" : "w-16 hover:w-56",
|
|
"bg-background/95 backdrop-blur border-r border-border",
|
|
"flex-col",
|
|
)}
|
|
>
|
|
<nav
|
|
className="flex-1 overflow-y-auto py-4 flex flex-col gap-0.5"
|
|
aria-label="Primary"
|
|
>
|
|
{TOP_ITEMS.map((item) => (
|
|
<SidebarLink
|
|
key={item.href}
|
|
item={item}
|
|
active={isActive(item)}
|
|
expandedOverride={readingOpen}
|
|
/>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="border-t border-border py-4 flex flex-col gap-0.5">
|
|
<SidebarReadingSettings
|
|
expanded={readingOpen}
|
|
onOpenChange={setReadingOpen}
|
|
/>
|
|
<SidebarProfile
|
|
authed={!!user}
|
|
handle={user?.handle ?? null}
|
|
name={user?.name ?? null}
|
|
avatarURL={user?.avatar_url ?? null}
|
|
active={pathname.startsWith("/u/me")}
|
|
expandedOverride={readingOpen}
|
|
/>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function SidebarLink({
|
|
item,
|
|
active,
|
|
expandedOverride,
|
|
}: {
|
|
item: NavItem;
|
|
active: boolean;
|
|
/** Force labels visible when the rail is pinned open (e.g. reading
|
|
* settings popover is up) and the mouse isn't actually hovering. */
|
|
expandedOverride?: boolean;
|
|
}) {
|
|
return (
|
|
<Link
|
|
href={item.href}
|
|
aria-label={item.label}
|
|
title={item.label}
|
|
className={cn(
|
|
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
|
"transition-colors",
|
|
active
|
|
? "bg-primary/10 text-primary"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
|
)}
|
|
>
|
|
<span className="shrink-0">{item.icon}</span>
|
|
<span
|
|
className={cn(
|
|
"transition-opacity duration-150",
|
|
expandedOverride
|
|
? "opacity-100"
|
|
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
|
|
)}
|
|
>
|
|
{item.label}
|
|
</span>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function SidebarReadingSettings({
|
|
expanded,
|
|
onOpenChange,
|
|
}: {
|
|
expanded: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
return (
|
|
<div className="mx-2">
|
|
<ReadingSettings
|
|
placement="above-left"
|
|
onOpenChange={onOpenChange}
|
|
renderTrigger={({ open, toggle, buttonRef }) => (
|
|
<button
|
|
ref={buttonRef}
|
|
type="button"
|
|
aria-label="Reading settings"
|
|
aria-expanded={open}
|
|
onClick={toggle}
|
|
className={cn(
|
|
"h-11 w-full flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
|
"text-muted-foreground hover:text-foreground hover:bg-muted transition-colors",
|
|
)}
|
|
>
|
|
<Settings2 className="size-5 shrink-0" />
|
|
<span
|
|
className={cn(
|
|
"transition-opacity duration-150",
|
|
expanded
|
|
? "opacity-100"
|
|
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
|
|
)}
|
|
>
|
|
Reading
|
|
</span>
|
|
</button>
|
|
)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SidebarProfile({
|
|
authed,
|
|
handle,
|
|
name,
|
|
avatarURL,
|
|
active,
|
|
expandedOverride,
|
|
}: {
|
|
authed: boolean;
|
|
handle: string | null;
|
|
name: string | null;
|
|
/** Uploaded avatar URL — when set, the rail glyph becomes a small
|
|
* circular picture instead of the generic person icon so the user
|
|
* recognises their seat in the nav at a glance. */
|
|
avatarURL?: string | null;
|
|
active: boolean;
|
|
expandedOverride?: boolean;
|
|
}) {
|
|
if (!authed) {
|
|
return (
|
|
<Link
|
|
href="/login"
|
|
className={cn(
|
|
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
|
"text-muted-foreground hover:text-foreground hover:bg-muted transition-colors",
|
|
)}
|
|
aria-label="Sign in"
|
|
title="Sign in"
|
|
>
|
|
<LogIn className="size-5 shrink-0" />
|
|
<span
|
|
className={cn(
|
|
"transition-opacity duration-150",
|
|
expandedOverride
|
|
? "opacity-100"
|
|
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
|
|
)}
|
|
>
|
|
Sign in
|
|
</span>
|
|
</Link>
|
|
);
|
|
}
|
|
return (
|
|
<Link
|
|
href="/u/me"
|
|
aria-label="Profile"
|
|
title={handle ? `@${handle}` : "Profile"}
|
|
className={cn(
|
|
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
|
"transition-colors",
|
|
active
|
|
? "bg-primary/10 text-primary"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
|
)}
|
|
>
|
|
{avatarURL ? (
|
|
<Image
|
|
src={avatarURL}
|
|
alt=""
|
|
width={20}
|
|
height={20}
|
|
className="size-5 rounded-full object-cover ring-1 ring-foreground/10 shrink-0"
|
|
unoptimized
|
|
/>
|
|
) : (
|
|
<UserIcon className="size-5 shrink-0" />
|
|
)}
|
|
<span
|
|
className={cn(
|
|
"min-w-0 truncate transition-opacity duration-150",
|
|
expandedOverride
|
|
? "opacity-100"
|
|
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
|
|
)}
|
|
>
|
|
{name || (handle ? `@${handle}` : "Profile")}
|
|
</span>
|
|
</Link>
|
|
);
|
|
}
|