feat: add mobile sidebar
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
"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) => {
|
||||
if (item.isParent) {
|
||||
return (
|
||||
<div
|
||||
key={item.slug}
|
||||
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={item.slug} 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Drawer.displayName = "Drawer";
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> & {
|
||||
direction?: "top" | "bottom" | "left" | "right";
|
||||
}
|
||||
>(({ className, children, direction = "bottom", ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col border bg-background",
|
||||
direction === "left" &&
|
||||
"inset-y-0 left-0 h-full w-[80%] max-w-sm rounded-r-[10px]",
|
||||
direction === "right" &&
|
||||
"inset-y-0 right-0 h-full w-[80%] max-w-sm rounded-l-[10px]",
|
||||
direction === "bottom" &&
|
||||
"inset-x-0 bottom-0 mt-24 h-auto rounded-t-[10px]",
|
||||
direction === "top" && "inset-x-0 top-0 mb-24 h-auto rounded-b-[10px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{(direction === "bottom" || direction === "top") && (
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
)}
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
));
|
||||
DrawerContent.displayName = "DrawerContent";
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerHeader.displayName = "DrawerHeader";
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerFooter.displayName = "DrawerFooter";
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ComponentRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
Reference in New Issue
Block a user