"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import {
ChevronDown,
ChevronUp,
EllipsisVertical,
PanelRight,
PanelRightClose,
FileText,
} from "lucide-react";
import { Dropdown } from "@docubook/ui-react/dropdown";
import { cn } from "../node/utils";
import Anchor from "./Anchor";
import { Context } from "./Context";
import Menu from "./Menu";
import { ThemeToggle } from "./Theme";
import { GitHubLink } from "./Navbar";
import Search from "./Search";
import type { TocItem } from "../node/types";
import { config as docuConfig, routes } from "../node/client-routes";
interface SidebarProps {
tocs?: TocItem[];
title?: string;
repoUrl?: string;
className?: string;
}
export default function Sidebar({ tocs = [], title, repoUrl, className }: SidebarProps) {
return (
<>
>
);
}
function DesktopSidebar({ className, repoUrl }: { className?: string; repoUrl?: string }) {
return (
{/* Logo */}
{/* Search */}
{/* Context + Menu */}
{/* Bottom: Theme + GitHub */}
);
}
export function MobileBar({
tocs,
title,
repoUrl,
}: {
tocs: TocItem[];
title?: string;
repoUrl?: string;
}) {
const [tocExpanded, setTocExpanded] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [activeId, setActiveId] = useState(null);
const barRef = useRef(null);
useEffect(() => {
if (!tocs.length) return;
// Match the CSS scroll-margin-top (scroll-mt-54 = 216px) that anchor
// jumps rest at, so the highlight agrees with where a jump lands.
const firstEl = document.getElementById(tocs[0].href.slice(1));
const offset = firstEl ? parseFloat(getComputedStyle(firstEl).scrollMarginTop) || 100 : 100;
const handleScroll = () => {
let currentId: string | null = null;
for (const toc of tocs) {
const id = toc.href.slice(1);
const el = document.getElementById(id);
if (!el) continue;
// Round: smooth anchor scrolls land at sub-pixel positions (e.g.
// 216.4px), which would otherwise fail the <= offset comparison and
// leave the previous heading highlighted.
if (Math.round(el.getBoundingClientRect().top) <= offset) {
currentId = id;
} else {
break;
}
}
if (currentId) setActiveId(currentId);
};
handleScroll();
window.addEventListener("scroll", handleScroll, { passive: true });
// Smooth anchor scrolls may not emit a trailing scroll event at the
// target position — recompute once the animation actually settles.
window.addEventListener("scrollend", handleScroll, { passive: true });
return () => {
window.removeEventListener("scroll", handleScroll);
window.removeEventListener("scrollend", handleScroll);
};
}, [tocs]);
useEffect(() => {
if (!tocExpanded) return;
const handler = (e: MouseEvent) => {
if (barRef.current && !barRef.current.contains(e.target as Node)) {
setTocExpanded(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [tocExpanded]);
const activeSection = tocs.find((t) => t.href.slice(1) === activeId);
const displayTitle = activeSection?.text || title || "On this page";
const toggleToc = useCallback(() => setTocExpanded((p) => !p), []);
return (
}
>
{(docuConfig.navbar?.menu || []).map((item) => (
path === item.href || path.startsWith(item.href + "/")
}
activeClassName="text-primary font-semibold"
className="block rounded px-2 py-2 text-sm"
>
{item.title}
))}
{tocExpanded && (
{tocs.length > 0 ? (
{
setTocExpanded(false);
setActiveId(id);
}}
/>
) : (
)}
)}
setDrawerOpen(false)} repoUrl={repoUrl} />
);
}
function MobileTocList({
tocs,
activeId,
onSelect,
}: {
tocs: TocItem[];
activeId: string | null;
onSelect: (id: string) => void;
}) {
return (
);
}
function MobileDrawer({
open,
onClose,
repoUrl,
}: {
open: boolean;
onClose: () => void;
repoUrl?: string;
}) {
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
if (!open) return null;
return (
);
}