initial docs

This commit is contained in:
2025-04-12 14:34:53 +07:00
commit ea9a71e23c
138 changed files with 23045 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
"use client";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ComponentProps, forwardRef } from "react";
type AnchorProps = ComponentProps<typeof Link> & {
absolute?: boolean;
activeClassName?: string;
disabled?: boolean;
};
const Anchor = forwardRef<HTMLAnchorElement, AnchorProps>(
({ absolute, className = "", activeClassName = "", disabled, children, ...props }, ref) => {
const path = usePathname();
const href = props.href.toString();
// Deteksi URL eksternal menggunakan regex
const isExternal = /^(https?:\/\/|\/\/)/.test(href);
let isMatch = absolute
? href.split("/")[1] === path.split("/")[1]
: path === href;
if (isExternal) isMatch = false; // Hindari mencocokkan URL eksternal
if (disabled)
return (
<div className={cn(className, "cursor-not-allowed")}>{children}</div>
);
return (
<Link ref={ref} className={cn(className, isMatch && activeClassName)} {...props}>
{children}
</Link>
);
}
);
Anchor.displayName = "Anchor";
export default Anchor;

View File

@@ -0,0 +1,47 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Fragment } from "react";
export default function DocsBreadcrumb({ paths }: { paths: string[] }) {
return (
<div className="pb-5">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink>Docs</BreadcrumbLink>
</BreadcrumbItem>
{paths.map((path, index) => (
<Fragment key={path}>
<BreadcrumbSeparator />
<BreadcrumbItem>
{index < paths.length - 1 ? (
<BreadcrumbLink className="a">
{toTitleCase(path)}
</BreadcrumbLink>
) : (
<BreadcrumbPage className="b">
{toTitleCase(path)}
</BreadcrumbPage>
)}
</BreadcrumbItem>
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
</div>
);
}
function toTitleCase(input: string): string {
const words = input.split("-");
const capitalizedWords = words.map(
(word) => word.charAt(0).toUpperCase() + word.slice(1)
);
return capitalizedWords.join(" ");
}

View File

@@ -0,0 +1,24 @@
"use client";
import { ROUTES } from "@/lib/routes-config";
import SubLink from "./sublink";
import { usePathname } from "next/navigation";
export default function DocsMenu({ isSheet = false }) {
const pathname = usePathname();
if (!pathname.startsWith("/docs")) return null;
return (
<div className="flex flex-col gap-3.5 mt-5 pr-2 pb-6">
{ROUTES.map((item, index) => {
const modifiedItems = {
...item,
href: `/docs${item.href}`,
level: 0,
isSheet,
};
return <SubLink key={item.title + index} {...modifiedItems} />;
})}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import React from 'react';
import docuConfig from '@/docu.json'; // Import JSON
import { SquarePenIcon } from 'lucide-react';
import Link from 'next/link';
interface EditThisPageProps {
filePath: string;
}
const EditThisPage: React.FC<EditThisPageProps> = ({ filePath }) => {
const { repository } = docuConfig;
const editUrl = `${repository.url}${repository.editPathTemplate.replace("{filePath}", filePath)}`;
return (
<div style={{ textAlign: 'right' }}>
<Link
href={editUrl}
target='_blank'
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
textDecoration: 'none',
fontWeight: 'bold',
}}
>
<span className='text-primary text-sm max-[480px]:hidden'>Edit this page</span>
<SquarePenIcon className="w-4 h-4 text-primary" />
</Link>
</div>
);
};
export default EditThisPage;

View File

@@ -0,0 +1,56 @@
import Link from "next/link";
import { ModeToggle } from "@/components/docs/theme-toggle";
import docuConfig from "@/docu.json";
import * as LucideIcons from "lucide-react"; // Import all icons
export function Footer() {
const { footer } = docuConfig;
const { meta } = docuConfig;
return (
<footer className="w-full py-4 px-2 border-t lg:py-8 bg-background">
<div className="container flex flex-wrap items-center justify-between text-sm">
<div className="items-start justify-center hidden gap-4 lg:flex-col lg:flex lg:w-3/5">
<h3 className="text-lg font-bold font-code">{meta.title}</h3>
<span className="w-3/4 text-base text-wrap text-muted-foreground">{meta.description}</span>
<div className="flex items-center gap-6 mt-2">
<FooterButtons />
</div>
</div>
<div className="flex flex-col items-start justify-center w-full gap-4 mt-4 xl:items-end lg:w-2/5">
<p className="text-center text-muted-foreground">
Copyright © {new Date().getFullYear()} {footer.copyright} - Made with{" "}
<Link href="https://www.docubook.pro" target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">
DocuBook
</Link>
</p>
<div className="hidden lg:flex">
<ModeToggle />
</div>
</div>
</div>
</footer>
);
}
export function FooterButtons() {
const { footer } = docuConfig;
return (
<>
{footer.social?.map((item) => {
const IconComponent = (LucideIcons[item.iconName as keyof typeof LucideIcons] ?? LucideIcons["Globe"]) as unknown as React.FC<{ className?: string }>;
return (
<Link
key={item.name}
href={item.url}
target="_blank"
rel="noopener noreferrer"
aria-label={item.name}
>
<IconComponent className="w-4 h-4 text-gray-800 transition-colors dark:text-gray-400 hover:text-primary" />
</Link>
);
})}
</>
);
}

View File

@@ -0,0 +1,59 @@
import {
Sheet,
SheetClose,
SheetContent,
SheetHeader,
SheetTrigger,
} from "@/components/ui/sheet";
import { Logo, NavMenu } from "./navbar";
import { Button } from "../ui/button";
import { AlignLeftIcon } from "lucide-react";
import { FooterButtons } from "./footer";
import { DialogTitle } from "../ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import DocsMenu from "./docs-menu";
import { ModeToggle } from "./theme-toggle";
export function Leftbar() {
return (
<aside className="lg:flex hidden flex-[1.5] min-w-[238px] sticky top-16 flex-col h-[93.75vh] overflow-y-auto">
<ScrollArea className="py-4">
<DocsMenu />
</ScrollArea>
</aside>
);
}
export function SheetLeftbar() {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="max-lg:flex hidden">
<AlignLeftIcon />
</Button>
</SheetTrigger>
<SheetContent className="flex flex-col gap-4 px-0" side="left">
<DialogTitle className="sr-only">Menu</DialogTitle>
<SheetHeader>
<SheetClose className="px-5" asChild>
<Logo />
</SheetClose>
</SheetHeader>
<div className="flex flex-col gap-4 overflow-y-auto">
<div className="flex flex-col gap-2.5 mt-3 mx-2 px-5">
<NavMenu isSheet />
</div>
<div className="mx-2 px-5">
<DocsMenu isSheet />
</div>
<div className="px-6 py-2 flex justify-start items-center gap-6">
<FooterButtons />
</div>
<div className="flex w-2/4 px-5">
<ModeToggle />
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { ListIcon } from "lucide-react";
import TocObserver from "./toc-observer";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
interface MobTocProps {
tocs: {
level: number;
text: string;
href: string;
}[];
}
export default function MobToc({ tocs }: MobTocProps) {
return (
<div className="lg:hidden block w-full">
<Accordion type="single" collapsible>
<AccordionItem value="toc">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-2">
<ListIcon className="w-4 h-4" />
<span className="font-medium text-sm">On this page</span>
</div>
</AccordionTrigger>
<AccordionContent className="h-auto py-2">
<TocObserver data={tocs} />
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { ArrowUpRight } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import Search from "./search";
import Anchor from "./anchor";
import { SheetLeftbar } from "./leftbar";
import { SheetClose } from "@/components/ui/sheet";
import docuConfig from "@/docu.json"; // Import JSON
export function Navbar() {
return (
<nav className="sticky top-0 z-50 w-full h-16 border-b bg-background">
<div className="sm:container mx-auto w-[95vw] h-full flex items-center justify-between md:gap-2">
<div className="flex items-center gap-5">
<SheetLeftbar />
<div className="flex items-center gap-6">
<div className="hidden sm:flex">
<Logo />
</div>
<div className="items-center hidden gap-4 text-sm font-medium lg:flex text-muted-foreground">
<NavMenu />
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Search />
</div>
</div>
</nav>
);
}
export function Logo() {
const { navbar } = docuConfig; // Extract navbar from JSON
return (
<Link href="/" className="flex items-center gap-2.5">
<Image src={navbar.logo.src} alt={navbar.logo.alt} width="24" height="24" />
<h2 className="font-bold font-code text-md">{navbar.logoText}</h2>
</Link>
);
}
export function NavMenu({ isSheet = false }) {
const { navbar } = docuConfig; // Extract navbar from JSON
return (
<>
{navbar?.menu?.map((item) => {
const isExternal = item.href.startsWith("http");
const Comp = (
<Anchor
key={`${item.title}-${item.href}`}
activeClassName="!text-primary md:font-semibold font-medium"
absolute
className="flex items-center gap-1 dark:text-stone-300/85 text-stone-800"
href={item.href}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
>
{item.title}
{isExternal && <ArrowUpRight className="w-4 h-4 text-muted-foreground" />}
</Anchor>
);
return isSheet ? (
<SheetClose key={item.title + item.href} asChild>
{Comp}
</SheetClose>
) : (
Comp
);
})}
</>
);
}

View File

@@ -0,0 +1,49 @@
import { getPreviousNext } from "@/lib/markdown";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import Link from "next/link";
import { buttonVariants } from "../ui/button";
export default function Pagination({ pathname }: { pathname: string }) {
const res = getPreviousNext(pathname);
return (
<div className="grid grid-cols-1 sm:grid-cols-2 flex-grow sm:py-10 py-7 gap-3">
<div>
{res.prev && (
<Link
className={buttonVariants({
variant: "outline",
className:
"no-underline w-full flex flex-col pl-3 !py-8 !items-start",
})}
href={`/docs${res.prev.href}`}
>
<span className="flex items-center text-xs">
<ChevronLeftIcon className="w-[1rem] h-[1rem] mr-1" />
Previous
</span>
<span className="mt-1 ml-1">{res.prev.title}</span>
</Link>
)}
</div>
<div>
{res.next && (
<Link
className={buttonVariants({
variant: "outline",
className:
"no-underline w-full flex flex-col pr-3 !py-8 !items-end",
})}
href={`/docs${res.next.href}`}
>
<span className="flex items-center text-xs">
Next
<ChevronRightIcon className="w-[1rem] h-[1rem] ml-1" />
</span>
<span className="mt-1 mr-1">{res.next.title}</span>
</Link>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { ArrowUpIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "../ui/button";
import { cn } from "@/lib/utils";
export function ScrollToTop() {
const [show, setShow] = useState(false);
useEffect(() => {
const handleScroll = () => {
// Check if user has scrolled to bottom
const scrolledToBottom =
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 100;
if (scrolledToBottom) {
setShow(true);
} else {
setShow(false);
}
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<div
className={cn(
"lg:hidden fixed top-16 items-center z-50 w-full transition-all duration-300",
show ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-full pointer-events-none"
)}
>
<div className="flex justify-center items-center pt-3 mx-auto">
<Button
variant="outline"
size="sm"
className="gap-2 rounded-full shadow-md bg-background/80 backdrop-blur-sm border-primary/20 hover:bg-background hover:text-primary"
onClick={scrollToTop}
>
<ArrowUpIcon className="h-4 w-4" />
<span className="font-medium">Scroll to Top</span>
</Button>
</div>
</div>
);
}

193
components/docs/search.tsx Normal file
View File

@@ -0,0 +1,193 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useRef } from "react";
import { ArrowUpIcon, ArrowDownIcon, CommandIcon, FileTextIcon, SearchIcon, CornerDownLeftIcon } from "lucide-react";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTrigger,
DialogClose,
DialogTitle,
} from "@/components/ui/dialog";
import Anchor from "./anchor";
import { advanceSearch, cn } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area";
export default function Search() {
const router = useRouter();
const [searchedInput, setSearchedInput] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
event.preventDefault();
setIsOpen(true);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
const filteredResults = useMemo(
() => advanceSearch(searchedInput.trim()),
[searchedInput]
);
useEffect(() => {
setSelectedIndex(0);
}, [filteredResults]);
useEffect(() => {
const handleNavigation = (event: KeyboardEvent) => {
if (!isOpen || filteredResults.length === 0) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setSelectedIndex((prev) => (prev + 1) % filteredResults.length);
}
if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedIndex((prev) => (prev - 1 + filteredResults.length) % filteredResults.length);
}
if (event.key === "Enter") {
event.preventDefault();
const selectedItem = filteredResults[selectedIndex];
if (selectedItem) {
router.push(`/docs${selectedItem.href}`);
setIsOpen(false);
}
}
};
window.addEventListener("keydown", handleNavigation);
return () => {
window.removeEventListener("keydown", handleNavigation);
};
}, [isOpen, filteredResults, selectedIndex, router]);
useEffect(() => {
if (itemRefs.current[selectedIndex]) {
itemRefs.current[selectedIndex]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [selectedIndex]);
return (
<div>
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) setSearchedInput("");
setIsOpen(open);
}}
>
<DialogTrigger asChild>
<div className="relative flex-1 cursor-pointer max-w-[160px]">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-500 dark:text-stone-400" />
<Input
className="md:w-full rounded-md dark:bg-background/95 bg-background border h-9 pl-10 pr-0 sm:pr-4 text-sm shadow-sm overflow-ellipsis"
placeholder="Search"
type="search"
/>
<div className="flex absolute top-1/2 -translate-y-1/2 right-2 text-xs font-medium font-mono items-center gap-0.5 dark:bg-black dark:border dark:border-white/20 bg-stone-200/50 border border-black/40 p-1 rounded-sm">
<CommandIcon className="w-3 h-3" />
<span>K</span>
</div>
</div>
</DialogTrigger>
<DialogContent className="p-0 max-w-[650px] sm:top-[38%] top-[45%] !rounded-md">
<DialogTitle className="sr-only">Search</DialogTitle>
<DialogHeader>
<input
value={searchedInput}
onChange={(e) => setSearchedInput(e.target.value)}
placeholder="Type something to search..."
autoFocus
className="h-14 px-6 bg-transparent border-b text-[14px] outline-none"
/>
</DialogHeader>
{filteredResults.length == 0 && searchedInput && (
<p className="text-muted-foreground mx-auto mt-2 text-sm">
No results found for{" "}
<span className="text-primary">{`"${searchedInput}"`}</span>
</p>
)}
<ScrollArea className="max-h-[400px] overflow-y-auto">
<div className="flex flex-col items-start overflow-y-auto sm:px-2 px-1 pb-4">
{filteredResults.map((item, index) => {
const level = (item.href.split("/").slice(1).length - 1) as keyof typeof paddingMap;
const paddingClass = paddingMap[level];
const isActive = index === selectedIndex;
return (
<DialogClose key={item.href} asChild>
<Anchor
ref={(el) => {
itemRefs.current[index] = el as HTMLDivElement | null;
}}
className={cn(
"dark:hover:bg-accent/15 hover:bg-accent/10 w-full px-3 rounded-sm text-sm flex items-center gap-2.5",
isActive && "bg-primary/20 dark:bg-primary/30",
paddingClass
)}
href={`/docs${item.href}`}
tabIndex={0}
>
<div
className={cn(
"flex items-center w-fit h-full py-3 gap-1.5 px-2",
level > 1 && "border-l pl-4"
)}
>
<FileTextIcon className="h-[1.1rem] w-[1.1rem] mr-1" /> {item.title}
</div>
</Anchor>
</DialogClose>
);
})}
</div>
</ScrollArea>
<DialogFooter className="md:flex md:justify-start hidden h-14 px-6 bg-transparent border-t text-[14px] outline-none">
<div className="flex items-center gap-2">
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2">
<ArrowUpIcon className="w-3 h-3"/>
</span>
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2">
<ArrowDownIcon className="w-3 h-3"/>
</span>
<p className="text-muted-foreground">to navigate</p>
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2">
<CornerDownLeftIcon className="w-3 h-3"/>
</span>
<p className="text-muted-foreground">to select</p>
<span className="dark:bg-accent/15 bg-slate-200 border rounded px-2 py-1">
esc
</span>
<p className="text-muted-foreground">to close</p>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
const paddingMap = {
1: "pl-2",
2: "pl-4",
3: "pl-10",
} as const;

View File

@@ -0,0 +1,85 @@
import { EachRoute } from "@/lib/routes-config";
import Anchor from "./anchor";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { SheetClose } from "@/components/ui/sheet";
import { ChevronDown, ChevronRight } from "lucide-react";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
export default function SubLink({
title,
href,
items,
noLink,
level,
isSheet,
}: EachRoute & { level: number; isSheet: boolean }) {
const path = usePathname();
const [isOpen, setIsOpen] = useState(level == 0);
useEffect(() => {
if (path == href || path.includes(href)) setIsOpen(true);
}, [href, path]);
const Comp = (
<Anchor activeClassName="text-primary font-medium" href={href}>
{title}
</Anchor>
);
const titleOrLink = !noLink ? (
isSheet ? (
<SheetClose asChild>{Comp}</SheetClose>
) : (
Comp
)
) : (
<h4 className="font-medium sm:text-sm text-primary">{title}</h4>
);
if (!items) {
return <div className="flex flex-col">{titleOrLink}</div>;
}
return (
<div className="flex flex-col gap-1 w-full">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger className="w-full pr-5">
<div className="flex items-center justify-between cursor-pointer w-full">
{titleOrLink}
<span>
{!isOpen ? (
<ChevronRight className="h-[0.9rem] w-[0.9rem]" />
) : (
<ChevronDown className="h-[0.9rem] w-[0.9rem]" />
)}
</span>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div
className={cn(
"flex flex-col items-start sm:text-sm dark:text-stone-300/85 text-stone-800 ml-0.5 mt-2.5 gap-3",
level > 0 && "pl-4 border-l ml-1.5"
)}
>
{items?.map((innerLink) => {
const modifiedItems = {
...innerLink,
href: `${href + innerLink.href}`,
level: level + 1,
isSheet,
};
return <SubLink key={modifiedItems.href} {...modifiedItems} />;
})}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import * as React from "react";
import { Moon, Sun, Monitor } from "lucide-react";
import { useTheme } from "next-themes";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
export function ModeToggle() {
const { theme, setTheme } = useTheme();
const [selectedTheme, setSelectedTheme] = React.useState<string>("system");
// Pastikan toggle tetap di posisi yang benar setelah reload
React.useEffect(() => {
if (theme) {
setSelectedTheme(theme);
} else {
setSelectedTheme("system"); // Default ke system jika undefined
}
}, [theme]);
return (
<ToggleGroup
type="single"
value={selectedTheme}
onValueChange={(value) => {
if (value) {
setTheme(value);
setSelectedTheme(value);
}
}}
className="flex items-center gap-1 rounded-full border border-gray-300 dark:border-gray-700 p-1 transition-all"
>
<ToggleGroupItem
value="light"
size="sm"
aria-label="Light Mode"
className={`rounded-full p-1 transition-all ${
selectedTheme === "light"
? "bg-blue-500 text-white"
: "bg-transparent"
}`}
>
<Sun className={`h-4 w-4 ${selectedTheme === "light" ? "text-white" : "text-gray-600 dark:text-gray-300"}`} />
</ToggleGroupItem>
<ToggleGroupItem
value="system"
size="sm"
aria-label="System Mode"
className={`rounded-full p-1 transition-all ${
selectedTheme === "system"
? "bg-blue-500 text-white"
: "bg-transparent"
}`}
>
<Monitor className={`h-4 w-4 ${selectedTheme === "system" ? "text-white" : "text-gray-600 dark:text-gray-300"}`} />
</ToggleGroupItem>
<ToggleGroupItem
value="dark"
size="sm"
aria-label="Dark Mode"
className={`rounded-full p-1 transition-all ${
selectedTheme === "dark"
? "bg-blue-500 text-white"
: "bg-transparent"
}`}
>
<Moon className={`h-4 w-4 ${selectedTheme === "dark" ? "text-white" : "text-gray-600 dark:text-gray-300"}`} />
</ToggleGroupItem>
</ToggleGroup>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import { getDocsTocs } from "@/lib/markdown";
import clsx from "clsx";
import Link from "next/link";
import { useState, useRef, useEffect } from "react";
type Props = { data: Awaited<ReturnType<typeof getDocsTocs>> };
export default function TocObserver({ data }: Props) {
const [activeId, setActiveId] = useState<string | null>(null);
const observer = useRef<IntersectionObserver | null>(null);
useEffect(() => {
const handleIntersect = (entries: IntersectionObserverEntry[]) => {
const visibleEntry = entries.find((entry) => entry.isIntersecting);
if (visibleEntry) {
setActiveId(visibleEntry.target.id);
}
};
observer.current = new IntersectionObserver(handleIntersect, {
root: null,
rootMargin: "-20px 0px 0px 0px",
threshold: 0.1,
});
const elements = data.map((item) =>
document.getElementById(item.href.slice(1))
);
elements.forEach((el) => {
if (el && observer.current) {
observer.current.observe(el);
}
});
return () => {
if (observer.current) {
elements.forEach((el) => {
if (el) {
observer.current!.unobserve(el);
}
});
}
};
}, [data]);
return (
<div className="flex flex-col gap-2.5 text-sm dark:text-stone-300/85 text-stone-800 ml-0.5">
{data.map(({ href, level, text }) => {
return (
<Link
key={href}
href={href}
className={clsx({
"pl-0": level == 2,
"pl-4": level == 3,
"pl-8 ": level == 4,
"font-medium text-primary": activeId == href.slice(1),
})}
>
{text}
</Link>
);
})}
</div>
);
}

21
components/docs/toc.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { getDocsTocs } from "@/lib/markdown";
import TocObserver from "./toc-observer";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ListIcon } from "lucide-react";
export default async function Toc({ path }: { path: string }) {
const tocs = await getDocsTocs(path);
return (
<div className="lg:flex hidden toc flex-[1.5] min-w-[238px] py-9 sticky top-16 h-[96.95vh]">
<div className="flex flex-col gap-3 w-full pl-2">
<div className="flex items-center gap-2">
<ListIcon className="w-5 h-5" /><h3 className="font-medium text-sm">On this page</h3>
</div>
<ScrollArea className="pb-2 pt-0.5 overflow-y-auto">
<TocObserver data={tocs} />
</ScrollArea>
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { PropsWithChildren } from "react";
export function Typography({ children }: PropsWithChildren) {
return (
<div className="prose prose-zinc dark:prose-invert prose-code:font-code dark:prose-code:bg-stone-900/25 prose-code:bg-stone-50 prose-pre:bg-background prose-headings:scroll-m-20 w-[85vw] sm:w-full sm:mx-auto prose-code:text-sm prose-code:leading-6 dark:prose-code:text-white prose-code:text-stone-800 prose-code:p-1 prose-code:rounded-md prose-code:border pt-2 !min-w-full prose-img:rounded-md prose-img:border prose-code:before:content-none prose-code:after:content-none prose-code:px-1.5 prose-code:overflow-x-auto !max-w-[500px] prose-img:my-3 prose-h2:my-4 prose-h2:mt-8">
{children}
</div>
);
}