131 lines
4.1 KiB
TypeScript
131 lines
4.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Link from "next/link";
|
|
import { Menu } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { buttonVariants } from "@/components/ui/button";
|
|
import {
|
|
Drawer,
|
|
DrawerClose,
|
|
DrawerContent,
|
|
DrawerHeader,
|
|
DrawerTitle,
|
|
DrawerTrigger,
|
|
} from "@/components/ui/drawer";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type MobileNavItem = {
|
|
slug: string;
|
|
label: string;
|
|
href: string;
|
|
type: string;
|
|
/** For hierarchical books (SSR, DS) — parent chapter number */
|
|
parentChapter?: number | null;
|
|
/** Whether this is a non-clickable parent heading */
|
|
isParent?: boolean;
|
|
};
|
|
|
|
export type MobileNavGroup = {
|
|
title?: string;
|
|
items: MobileNavItem[];
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Mobile sidebar drawer triggered by a menu button.
|
|
*
|
|
* Usage: Place this component alongside the prev/next buttons in any
|
|
* book reader page. It renders a menu icon button (visible only on
|
|
* mobile / below `lg` breakpoint) that opens a bottom drawer with
|
|
* the full sidebar navigation.
|
|
*
|
|
* Supports two modes:
|
|
* 1. **Flat groups** — pass `groups` for simple grouped lists
|
|
* (Front Matter, Chapters, etc.)
|
|
* 2. **Hierarchical** — pass `groups` where some items have
|
|
* `isParent: true` to render parent headings with indented children
|
|
*/
|
|
export function MobileNav({
|
|
groups,
|
|
currentSlug,
|
|
bookTitle,
|
|
}: {
|
|
groups: MobileNavGroup[];
|
|
currentSlug: string;
|
|
bookTitle?: string;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
return (
|
|
<Drawer open={open} onOpenChange={setOpen} direction="left">
|
|
<DrawerTrigger
|
|
className={cn(
|
|
buttonVariants({ variant: "outline", size: "sm" }),
|
|
"lg:hidden gap-1.5",
|
|
)}
|
|
>
|
|
<Menu className="size-4" />
|
|
<span className="hidden sm:inline">Contents</span>
|
|
</DrawerTrigger>
|
|
<DrawerContent direction="left" className="h-full">
|
|
<DrawerHeader className="text-left border-b border-border pb-4 mb-2">
|
|
<DrawerTitle>{bookTitle ?? "Contents"}</DrawerTitle>
|
|
</DrawerHeader>
|
|
<nav className="overflow-y-auto px-4 pb-6 pt-2 space-y-4">
|
|
{groups.map((group, gi) => (
|
|
<div key={gi}>
|
|
{group.title && (
|
|
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-2 px-2">
|
|
{group.title}
|
|
</h3>
|
|
)}
|
|
<div className="flex flex-col gap-0.5">
|
|
{group.items.map((item, ii) => {
|
|
if (item.isParent) {
|
|
return (
|
|
<div
|
|
key={`${gi}-${ii}`}
|
|
className="px-2 py-1.5 text-xs font-bold text-muted-foreground mt-2"
|
|
>
|
|
{item.label}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isActive = item.slug === currentSlug;
|
|
const isChild = item.parentChapter != null && !item.isParent;
|
|
|
|
return (
|
|
<DrawerClose key={`${gi}-${ii}`} asChild>
|
|
<Link
|
|
href={item.href}
|
|
className={cn(
|
|
"px-2 py-2 rounded-md text-sm transition-colors truncate block",
|
|
isChild && "pl-4",
|
|
isActive
|
|
? "bg-primary/10 text-primary font-medium"
|
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
)}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
</DrawerClose>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</nav>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
);
|
|
}
|