Files
spsays-training/components/layout/site-sidebar.tsx
T
2026-07-02 21:32:22 +05:30

330 lines
8.8 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,
DownloadCloud,
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" />,
},
{
href: "/offline-content",
label: "Offline",
icon: <DownloadCloud 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>
);
}