feat: add mobile sidebar

This commit is contained in:
gosangam
2026-04-17 23:20:59 +05:30
parent 0aab1d23ec
commit 9b5823146f
30 changed files with 2502 additions and 414 deletions
+130
View File
@@ -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>
);
}
+129
View File
@@ -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,
};