update versi docubook
This commit is contained in:
43
components/docs/anchor.tsx
Normal file
43
components/docs/anchor.tsx
Normal 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;
|
||||
47
components/docs/docs-breadcrumb.tsx
Normal file
47
components/docs/docs-breadcrumb.tsx
Normal 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(" ");
|
||||
}
|
||||
24
components/docs/docs-menu.tsx
Normal file
24
components/docs/docs-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
components/docs/edit-on-github.tsx
Normal file
35
components/docs/edit-on-github.tsx
Normal 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;
|
||||
56
components/docs/footer.tsx
Normal file
56
components/docs/footer.tsx
Normal 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 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
components/docs/leftbar.tsx
Normal file
59
components/docs/leftbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
components/docs/mob-toc.tsx
Normal file
38
components/docs/mob-toc.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
components/docs/navbar.tsx
Normal file
78
components/docs/navbar.tsx
Normal 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
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
49
components/docs/pagination.tsx
Normal file
49
components/docs/pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
components/docs/scroll-to-top.tsx
Normal file
52
components/docs/scroll-to-top.tsx
Normal 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
193
components/docs/search.tsx
Normal 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;
|
||||
85
components/docs/sublink.tsx
Normal file
85
components/docs/sublink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
components/docs/theme-toggle.tsx
Normal file
71
components/docs/theme-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
components/docs/toc-observer.tsx
Normal file
69
components/docs/toc-observer.tsx
Normal 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
21
components/docs/toc.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
components/docs/typography.tsx
Normal file
9
components/docs/typography.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user