docu v 1.11.0

This commit is contained in:
Wildan Nursahidan
2025-05-26 22:28:27 +07:00
parent bbf23b66eb
commit 3f6a407a30
57 changed files with 1742 additions and 3924 deletions

View File

@@ -0,0 +1,44 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
const GitHubStarButton: React.FC = () => {
const [stars, setStars] = useState<number | null>(null);
useEffect(() => {
fetch('https://api.github.com/repos/gitfromwildan/docubook')
.then((res) => res.json())
.then((data) => {
if (data.stargazers_count !== undefined) {
setStars(data.stargazers_count);
}
})
.catch((error) => console.error('Failed to fetch stars:', error));
}, []);
const formatStars = (count: number) =>
count >= 1000 ? `${(count / 1000).toFixed(1)}K` : `${count}`;
return (
<Link
href="https://github.com/gitfromwildan/docubook"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center rounded-full px-3 py-1.5 text-sm font-medium text-muted-foreground border no-underline"
>
<svg
height="16"
width="16"
viewBox="0 0 16 16"
aria-hidden="true"
className="fill-current mr-1.5"
>
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38v-1.32c-2.22.48-2.69-1.07-2.69-1.07-.36-.92-.89-1.17-.89-1.17-.73-.5.06-.49.06-.49.81.06 1.23.83 1.23.83.72 1.23 1.89.88 2.35.67.07-.52.28-.88.5-1.08-1.77-.2-3.64-.88-3.64-3.93 0-.87.31-1.58.82-2.14-.08-.2-.36-1.01.08-2.12 0 0 .67-.21 2.2.82a7.7 7.7 0 012.01-.27 7.7 7.7 0 012.01.27c1.53-1.03 2.2-.82 2.2-.82.44 1.11.16 1.92.08 2.12.51.56.82 1.27.82 2.14 0 3.06-1.87 3.73-3.65 3.93.29.25.54.73.54 1.48v2.2c0 .21.15.46.55.38A8 8 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
{stars !== null ? formatStars(stars) : '...'}
</Link>
);
};
export default GitHubStarButton;

View File

@@ -1,37 +1,78 @@
"use client";
import { cn } from "@/lib/utils";
import Link from "next/link";
import Link, { LinkProps } from "next/link";
import { usePathname } from "next/navigation";
import { ComponentProps, forwardRef } from "react";
import { forwardRef } from "react";
type AnchorProps = ComponentProps<typeof Link> & {
type AnchorProps = LinkProps & {
absolute?: boolean;
activeClassName?: string;
disabled?: boolean;
};
className?: string;
children: React.ReactNode;
} & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps>;
const Anchor = forwardRef<HTMLAnchorElement, AnchorProps>(
({ absolute, className = "", activeClassName = "", disabled, children, ...props }, ref) => {
({
absolute = false,
className = "",
activeClassName = "",
disabled = false,
children,
href,
...props
}, ref) => {
const path = usePathname();
const href = props.href.toString();
const hrefStr = href?.toString() || '';
// Deteksi URL eksternal menggunakan regex
const isExternal = /^(https?:\/\/|\/\/)/.test(href);
// Check if URL is external
const isExternal = /^(https?:\/\/|\/\/)/.test(hrefStr);
let isMatch = absolute
? href.split("/")[1] === path.split("/")[1]
: path === href;
// Check if current path matches the link
const isActive = absolute
? hrefStr.split("/")[1] === path?.split("/")[1]
: path === hrefStr;
if (isExternal) isMatch = false; // Hindari mencocokkan URL eksternal
// Apply active class only for internal links
const linkClassName = cn(
'transition-colors hover:text-primary',
className,
!isExternal && isActive && activeClassName
);
if (disabled)
if (disabled) {
return (
<div className={cn(className, "cursor-not-allowed")}>{children}</div>
<span className={cn(linkClassName, "cursor-not-allowed opacity-50")}>
{children}
</span>
);
}
if (isExternal) {
return (
<a
ref={ref}
href={hrefStr}
className={linkClassName}
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
}
return (
<Link ref={ref} className={cn(className, isMatch && activeClassName)} {...props}>
<Link
ref={ref}
href={hrefStr}
className={linkClassName}
{...props}
>
{children}
</Link>
);

View File

@@ -1,45 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
type ChangeType = "Added" | "Improved" | "Fixed" | "Deprecated" | "Removed";
interface ChangeGroupProps {
type: ChangeType;
changes: string[];
expanded: boolean;
}
const typeColors: Record<ChangeType, string> = {
Added: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
Improved: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
Fixed: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
Deprecated: "bg-red-500/10 text-red-600 dark:text-red-400",
Removed: "bg-slate-500/10 text-slate-600 dark:text-slate-400"
};
export function ChangeGroup({ type, changes, expanded }: ChangeGroupProps) {
const visibleChanges = expanded ? changes : changes.slice(0, 5);
const hasMore = changes.length > 5;
return (
<div className="space-y-3">
<Badge variant="outline" className={cn("font-medium", typeColors[type])}>
{type}
</Badge>
<ul className="list-disc list-inside space-y-2 text-muted-foreground pl-2">
{visibleChanges.map((change, i) => (
<li key={i} id="changelog" className="text-sm leading-relaxed marker:text-muted-foreground/60">
{change}
</li>
))}
{!expanded && hasMore && (
<li id="changelog-more" className="text-sm text-muted-foreground/60">
+{changes.length - 5} more improvements
</li>
)}
</ul>
</div>
);
}

View File

@@ -1,86 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { History } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
interface FloatingVersionTocProps {
versions: { version: string; date: string }[];
}
export function FloatingVersionToc({ versions }: FloatingVersionTocProps) {
const [open, setOpen] = useState(false);
const [activeVersion, setActiveVersion] = useState(versions[0]?.version || "");
useEffect(() => {
const handleIntersection = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveVersion(entry.target.id.replace("version-", ""));
}
});
};
const observer = new IntersectionObserver(handleIntersection, {
root: null,
rootMargin: "-64px 0px -50% 0px",
threshold: 0.25,
});
versions.forEach(({ version }) => {
const section = document.getElementById(`version-${version}`);
if (section) observer.observe(section);
});
return () => observer.disconnect();
}, [versions]);
const handleScrollToVersion = (version: string) => {
const element = document.getElementById(`version-${version}`);
if (element) {
setTimeout(() => {
element.scrollIntoView({ behavior: "smooth", block: "start" });
}, 100);
setActiveVersion(version);
setOpen(false);
}
};
return (
<div className="fixed bottom-4 right-4 md:hidden z-50">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="rounded-full shadow-lg px-4 py-2 flex items-center gap-2">
<History className="w-5 h-5" />
Version - {activeVersion}
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2 bg-background shadow-md rounded-lg">
<ScrollArea className="h-72">
<h2 className="px-4 py-2 font-semibold">Version History</h2>
<ul className="space-y-1">
{versions.map(({ version }) => (
<li key={version}>
<Separator />
<Button
variant="ghost"
className={cn("w-full justify-start text-sm", {
"text-primary font-bold": activeVersion === version,
})}
onClick={() => handleScrollToVersion(version)}
>
v.{version}
</Button>
</li>
))}
</ul>
</ScrollArea>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -1,113 +0,0 @@
"use client";
import { useState } from "react";
import { VersionTag } from "./version-tag";
import { ChangeGroup } from "./change-group";
import { formatDate2 } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import Image from "next/image";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Separator } from "@/components/ui/separator";
interface VersionEntryProps {
version: string;
date: string;
description?: string;
image?: string;
changes: {
Added?: string[];
Improved?: string[];
Fixed?: string[];
Deprecated?: string[];
Removed?: string[];
};
isLast?: boolean;
}
export function VersionEntry({
version,
date,
description,
image,
changes,
isLast
}: VersionEntryProps) {
const [expanded, setExpanded] = useState(false);
return (
<div id={`v${version}`} className="relative scroll-mt-24">
<div className="relative pb-12">
{/* Version header */}
<div className="flex flex-col gap-4 mb-6">
<div className="flex items-center gap-3">
<VersionTag version={version} />
<time className="text-sm text-muted-foreground">
{formatDate2(date)}
</time>
</div>
{description && (
<p className="text-dark text-xl">{description}</p>
)}
{image && (
<div className="relative w-full h-0 pb-[56.25%] rounded-lg overflow-hidden border">
<Image
src={image}
alt={`Version ${version} preview`}
fill
className="object-cover"
priority
quality={90}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
</div>
)}
</div>
{/* Changes */}
<div className="space-y-6">
{Object.entries(changes).map(([type, items]) => (
items && items.length > 0 && (
<ChangeGroup
key={type}
type={type as keyof typeof changes}
changes={items}
expanded={expanded}
/>
)
))}
</div>
{/* Show more/less button */}
{Object.values(changes).some(items => items && items.length > 5) && (
<Button
variant="outline"
size="sm"
onClick={() => setExpanded(!expanded)}
className="mt-4 text-muted-foreground hover:bg-transparent hover:text-accent border-none"
>
{expanded ? (
<>
Show less
<ChevronUpIcon className="ml-2 h-4 w-4" />
</>
) : (
<>
Show more
<ChevronDownIcon className="ml-2 h-4 w-4" />
</>
)}
</Button>
)}
</div>
{/* Version divider */}
{!isLast && (
<div className="absolute left-0 bottom-0 w-full">
<Separator className="my-8" />
</div>
)}
</div>
);
}

View File

@@ -1,14 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
export function VersionTag({ version }: { version: string }) {
return (
<span className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-medium",
"bg-primary/10 text-primary"
)}>
v{version}
</span>
);
}

View File

@@ -1,111 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { cn, formatDate2 } from "@/lib/utils";
import { History, PanelLeftOpen, PanelLeftClose } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
interface VersionTocProps {
versions: Array<{
version: string;
date: string;
}>;
}
export function VersionToc({ versions }: VersionTocProps) {
const [activeId, setActiveId] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
const hash = window.location.hash.slice(1);
if (hash) {
setActiveId(hash);
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const id = entry.target.id;
setActiveId(id);
window.history.pushState(null, "", `#${id}`);
}
});
},
{
threshold: 0.2,
rootMargin: "-20% 0px -60% 0px",
}
);
versions.forEach(({ version }) => {
const element = document.getElementById(`v${version}`);
if (element) observer.observe(element);
});
return () => observer.disconnect();
}, [versions]);
return (
<aside
className={cn(
"sticky top-16 h-[calc(100vh-4rem)] border-r bg-background transition-all duration-300 z-20 hidden md:flex",
collapsed ? "w-[48px]" : "w-[250px]"
)}
>
{/* Toggle Button */}
<div className="absolute top-0 right-0 py-2 px-0 ml-6 z-30">
<Button
size="icon"
variant="outline"
className="hover:bg-transparent hover:text-inherit border-none text-muted-foreground"
onClick={() => setCollapsed((prev) => !prev)}
>
{collapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
</Button>
</div>
{/* Content */}
{!collapsed && (
<div className="flex flex-col gap-2 w-full pt-8 pr-2">
<div className="flex mb-2">
<h2 className="font-semibold text-lg">Changelog</h2>
</div>
<div className="flex items-center gap-2 mb-2">
<History className="w-4 h-4" />
<h3 className="font-medium text-sm">Version History</h3>
</div>
<ScrollArea className="h-full pr-2">
<div className="flex flex-col gap-1.5 text-sm dark:text-stone-300/85 text-stone-800 pr-4">
{versions.map(({ version, date }) => (
<a
key={version}
href={`#v${version}`}
className={cn(
"hover:text-foreground transition-colors py-1",
activeId === `v${version}` && "font-medium text-primary"
)}
onClick={(e) => {
e.preventDefault();
const element = document.getElementById(`v${version}`);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
setActiveId(`v${version}`);
window.history.pushState(null, "", `#v${version}`);
}
}}
>
v{version}
<span className="text-xs text-muted-foreground ml-2">
{formatDate2(date)}
</span>
</a>
))}
</div>
</ScrollArea>
</div>
)}
</aside>
);
}

View File

@@ -10,7 +10,7 @@ import { Fragment } from "react";
export default function DocsBreadcrumb({ paths }: { paths: string[] }) {
return (
<div className="pb-5">
<div className="pb-5 max-lg:pt-12">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>

View File

@@ -15,44 +15,26 @@ interface FooterConfig {
social?: SocialItem[];
}
interface MetaConfig {
title: string;
description: string;
baseURL: string;
favicon: string;
}
interface DocuConfig {
footer: FooterConfig;
meta: MetaConfig;
navbar: any;
repository: any;
routes: any[];
}
// Type assertion for docu.json
const docuConfig = docuData as DocuConfig;
const docuConfig = docuData as {
footer: FooterConfig;
};
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">
<footer className="w-full py-8 border-t bg-background">
<div className="container flex flex-col lg:flex-row items-center justify-between text-sm">
<div className="flex flex-col items-center lg:items-start justify-start gap-4 w-full lg:w-3/5 text-center lg:text-left">
<p className="text-muted-foreground">
Copyright © {new Date().getFullYear()} {footer.copyright} - <MadeWith />
</p>
<div className="flex items-center justify-center lg:justify-start gap-6 mt-2 w-full">
<FooterButtons />
</div>
</div>
<div className="flex flex-col items-center justify-center w-full gap-4 mt-4 lg:items-end lg:w-2/5">
<p className="text-center text-muted-foreground">
Copyright © {new Date().getFullYear()} {footer.copyright} - <MadeWith />
</p>
<div className="hidden lg:flex">
<ModeToggle />
</div>
<div className="hidden lg:flex items-center justify-end lg:w-2/5">
<ModeToggle />
</div>
</div>
</footer>

View File

@@ -11,37 +11,49 @@ import { Logo, NavMenu } from "@/components/navbar";
import { Button } from "@/components/ui/button";
import { AlignLeftIcon, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { FooterButtons } from "@/components/footer";
import { DialogTitle } from "@/components/ui/dialog";
import { DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import DocsMenu from "@/components/docs-menu";
import { ModeToggle } from "@/components/theme-toggle";
// Toggle Button Component
export function ToggleButton({
collapsed,
onToggle
}: {
collapsed: boolean,
onToggle: () => void
}) {
return (
<div className="absolute top-0 right-0 py-6 z-10 -mt-4">
<Button
size="icon"
variant="outline"
className="hover:bg-transparent hover:text-inherit border-none text-muted-foreground"
onClick={onToggle}
>
{collapsed ? (
<PanelLeftOpen size={18} />
) : (
<PanelLeftClose size={18} />
)}
</Button>
</div>
)
}
export function Leftbar() {
const [collapsed, setCollapsed] = useState(false);
const toggleCollapse = () => setCollapsed(prev => !prev);
return (
<aside
className={`sticky lg:flex hidden top-16 h-[calc(100vh-4rem)] border-r bg-background transition-all duration-300
${collapsed ? "w-[48px]" : "w-[250px]"} flex flex-col pr-2`}
${collapsed ? "w-[24px]" : "w-[280px]"} flex flex-col pr-2`}
>
{/* Toggle Button */}
<div className="absolute top-0 right-0 py-6 px-0 ml-6 z-10 -mt-4">
<Button
size="icon"
variant="outline"
className="hover:bg-transparent hover:text-inherit border-none text-muted-foreground"
onClick={() => setCollapsed((prev) => !prev)}
>
{collapsed ? (
<PanelLeftOpen size={18} />
) : (
<PanelLeftClose size={18} />
)}
</Button>
</div>
<ToggleButton collapsed={collapsed} onToggle={toggleCollapse} />
{/* Scrollable DocsMenu */}
<ScrollArea className="flex-1 px-2 pb-4">
<ScrollArea className="flex-1 px-0.5 pb-4">
{!collapsed && <DocsMenu />}
</ScrollArea>
</aside>
@@ -57,7 +69,10 @@ export function SheetLeftbar() {
</Button>
</SheetTrigger>
<SheetContent className="flex flex-col gap-4 px-0" side="left">
<DialogTitle className="sr-only">Menu</DialogTitle>
<DialogTitle className="sr-only">Navigation Menu</DialogTitle>
<DialogDescription className="sr-only">
Main navigation menu with links to different sections
</DialogDescription>
<SheetHeader>
<SheetClose className="px-5" asChild>
<span className="px-2"><Logo /></span>
@@ -70,9 +85,6 @@ export function SheetLeftbar() {
<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>

View File

@@ -0,0 +1,102 @@
import React from 'react';
// Map of special keys to their Mac symbols
const macKeyMap: Record<string, string> = {
command: '⌘',
cmd: '⌘',
option: '⌥',
alt: '⌥',
shift: '⇧',
ctrl: '⌃',
control: '⌃',
tab: '⇥',
caps: '⇪',
enter: '⏎',
return: '⏎',
delete: '⌫',
escape: '⎋',
esc: '⎋',
up: '↑',
down: '↓',
left: '←',
right: '→',
space: '␣',
};
// Map of special keys to their Windows display text
const windowsKeyMap: Record<string, string> = {
command: 'Win',
cmd: 'Win',
option: 'Alt',
alt: 'Alt',
ctrl: 'Ctrl',
control: 'Ctrl',
delete: 'Del',
escape: 'Esc',
esc: 'Esc',
enter: 'Enter',
return: 'Enter',
tab: 'Tab',
caps: 'Caps',
shift: 'Shift',
space: 'Space',
up: '↑',
down: '↓',
left: '←',
right: '→',
};
export interface KbdProps extends React.HTMLAttributes<HTMLElement> {
/** The key to display (e.g., 'cmd', 'ctrl', 'a') */
show?: string;
/** Platform style - 'window' or 'mac' */
type?: 'window' | 'mac';
/** Custom content to display (overrides automatic rendering) */
children?: React.ReactNode;
}
const KbdComponent: React.FC<KbdProps> = ({
show: keyProp,
type = 'window',
children,
...props
}) => {
// Get the display text based on the key and type
const getKeyDisplay = (): React.ReactNode => {
if (!keyProp || typeof keyProp !== 'string') return null;
const lowerKey = keyProp.toLowerCase();
// For Mac type, return the symbol if it exists
if (type === 'mac') {
return macKeyMap[lowerKey] || keyProp;
}
// For Windows, return the formatted key if it exists, otherwise capitalize the first letter
return windowsKeyMap[lowerKey] || (keyProp.charAt(0).toUpperCase() + keyProp.slice(1));
};
// Determine what to render
const renderContent = () => {
// If children are provided, always use them
if (children !== undefined && children !== null && children !== '') {
return children;
}
// Otherwise use the generated display
return getKeyDisplay() || keyProp || '';
};
return (
<kbd
className="inline-flex items-center justify-center px-2 py-1 mx-0.5 text-xs font-mono font-medium text-gray-800 bg-gray-100 border border-gray-300 rounded-md dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600"
{...props}
>
{renderContent()}
</kbd>
);
};
// Export the component
export const Kbd = KbdComponent;
// Default export for backward compatibility
export default KbdComponent;

View File

@@ -1,5 +1,5 @@
import { ComponentProps } from "react";
import Copy from "./copy";
import Copy from "./CopyMdx";
export default function Pre({
children,

View File

@@ -0,0 +1,109 @@
import React, { PropsWithChildren } from 'react';
import { cn } from '@/lib/utils';
import { PlusCircle, Wrench, Zap, AlertTriangle, XCircle } from 'lucide-react';
interface ReleaseProps extends PropsWithChildren {
version: string;
title: string;
date?: string;
}
function Release({ version, title, date, children }: ReleaseProps) {
return (
<div className="mb-16 group">
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<div className="bg-primary/10 text-primary border-2 border-primary/20 rounded-full px-4 py-1.5 text-base font-medium">
v{version}
</div>
{date && (
<div className="text-muted-foreground text-sm">
{new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
)}
</div>
<h2 className="text-2xl font-bold text-foreground/90 mb-3">
{title}
</h2>
</div>
<div className="space-y-8">
{children}
</div>
</div>
);
}
interface ChangesProps extends PropsWithChildren {
type: 'added' | 'fixed' | 'improved' | 'deprecated' | 'removed';
}
const typeConfig = {
added: {
label: 'Added',
className: 'bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300',
icon: PlusCircle,
},
fixed: {
label: 'Fixed',
className: 'bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300',
icon: Wrench,
},
improved: {
label: 'Improved',
className: 'bg-cyan-100 dark:bg-cyan-900/50 text-cyan-700 dark:text-cyan-300',
icon: Zap,
},
deprecated: {
label: 'Deprecated',
className: 'bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300',
icon: AlertTriangle,
},
removed: {
label: 'Removed',
className: 'bg-pink-100 dark:bg-pink-900/50 text-pink-700 dark:text-pink-300',
icon: XCircle,
},
} as const;
function Changes({ type, children }: ChangesProps) {
const config = typeConfig[type] || typeConfig.added;
return (
<div className="space-y-3 mb-8">
<div className="flex items-center gap-2">
<div className={cn("px-3 py-1 rounded-full text-sm font-medium flex items-center gap-1.5", config.className)}>
<config.icon className="h-3.5 w-3.5" />
<span>{config.label}</span>
</div>
</div>
<ul className="list-none pl-0 space-y-2 text-foreground/80">
{React.Children.map(children, (child, index) => {
// Jika teks dimulai dengan - atau *, hapus karakter tersebut
const processedChild = typeof child === 'string'
? child.trim().replace(/^[-*]\s+/, '')
: child;
return (
<li key={index} className="leading-relaxed">
{processedChild}
</li>
);
})}
</ul>
</div>
);
}
export { Release, Changes };
const ReleaseMdx = {
Release,
Changes
};
export default ReleaseMdx;

View File

@@ -0,0 +1,29 @@
'use client';
import { MDXRemote, MDXRemoteProps } from 'next-mdx-remote/rsc';
import { Kbd } from './KeyboardMdx';
// Define components mapping
const components = {
// Keyboard components
Kbd: Kbd as React.ComponentType<React.HTMLAttributes<HTMLElement> & { type?: 'window' | 'mac' }>,
kbd: Kbd as React.ComponentType<React.HTMLAttributes<HTMLElement> & { type?: 'window' | 'mac' }>,
};
interface MDXProviderWrapperProps {
source: string;
}
export function MDXProviderWrapper({ source }: MDXProviderWrapperProps) {
return (
<div className="prose dark:prose-invert max-w-none">
<MDXRemote
source={source}
components={components}
options={{
parseFrontmatter: true,
}}
/>
</div>
);
}

View File

@@ -1,38 +1,128 @@
"use client";
import { ListIcon } from "lucide-react";
import { List, ChevronDown, ChevronUp } from "lucide-react";
import TocObserver from "./toc-observer";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import * as React from "react";
import { useRef, useMemo } from "react";
import { usePathname } from "next/navigation";
import { Button } from "./ui/button";
import { motion, AnimatePresence } from "framer-motion";
import { useScrollPosition, useActiveSection } from "@/hooks";
import { TocItem } from "@/lib/toc";
interface MobTocProps {
tocs: {
level: number;
text: string;
href: string;
}[];
tocs: TocItem[];
}
const useClickOutside = (ref: React.RefObject<HTMLElement>, callback: () => void) => {
const handleClick = React.useCallback((event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
}, [ref, callback]);
React.useEffect(() => {
document.addEventListener('mousedown', handleClick);
return () => {
document.removeEventListener('mousedown', handleClick);
};
}, [handleClick]);
};
export default function MobToc({ tocs }: MobTocProps) {
const pathname = usePathname();
const [isExpanded, setIsExpanded] = React.useState(false);
const tocRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
// Use custom hooks
const { activeId, setActiveId } = useActiveSection(tocs);
// Only show on /docs pages
const isDocsPage = useMemo(() => pathname?.startsWith('/docs'), [pathname]);
// Toggle expanded state
const toggleExpanded = React.useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setIsExpanded(prev => !prev);
}, []);
// Close TOC when clicking outside
useClickOutside(tocRef, () => {
if (isExpanded) {
setIsExpanded(false);
}
});
// Handle body overflow when TOC is expanded
React.useEffect(() => {
if (isExpanded) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isExpanded]);
// Don't render anything if not on docs page or no TOC items
if (!isDocsPage || !tocs?.length) return null;
const chevronIcon = isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
);
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>
<AnimatePresence>
<motion.div
ref={tocRef}
className="lg:hidden fixed top-16 left-0 right-0 z-50"
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -100, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<div className="w-full bg-background/95 backdrop-blur-sm border-b border-stone-200 dark:border-stone-800 shadow-sm">
<div className="sm:px-8 px-4 py-2">
<Button
variant="ghost"
size="sm"
className="w-full justify-between h-auto py-2 px-2 -mx-1 rounded-md hover:bg-transparent hover:text-inherit"
onClick={toggleExpanded}
aria-label={isExpanded ? 'Collapse table of contents' : 'Expand table of contents'}
>
<div className="flex items-center gap-2">
<List className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="font-medium text-sm">On this page</span>
</div>
{chevronIcon}
</Button>
<AnimatePresence>
{isExpanded && (
<motion.div
ref={contentRef}
className="mt-2 pb-2 max-h-[60vh] overflow-y-auto px-1 -mx-1"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<TocObserver
data={tocs}
activeId={activeId}
onActiveIdChange={setActiveId}
/>
</motion.div>
)}
</AnimatePresence>
</div>
</AccordionTrigger>
<AccordionContent className="h-auto py-2">
<TocObserver data={tocs} />
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -34,14 +34,22 @@ export function Navbar() {
}
export function Logo() {
const { navbar } = docuConfig; // Extract navbar from JSON
const { navbar } = docuConfig; // Extract navbar from JSON
return (
<Link href="/" className="flex items-center gap-1.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>
);
return (
<Link href="/" className="flex items-center gap-1.5">
<div className="relative w-8 h-8">
<Image
src={navbar.logo.src}
alt={navbar.logo.alt}
fill
sizes="32px"
className="object-contain"
/>
</div>
<h2 className="font-bold font-code text-md">{navbar.logoText}</h2>
</Link>
);
}
export function NavMenu({ isSheet = false }) {

View File

@@ -1,252 +0,0 @@
type InsertAtCursor = (textArea: HTMLTextAreaElement, text: string) => void;
// toolbar handler
export const handleMetadataClick = (insertAtCursor: InsertAtCursor) => {
const textArea = document.querySelector("textarea");
if (textArea) {
const metadata = `---
title: Post Title
description: Your Post Description
date: ${new Date().toISOString().split("T")[0]}
image: example-img.png
---\n\n`;
insertAtCursor(textArea, metadata);
}
};
export const handleParagraphClick = (insertAtCursor: InsertAtCursor) => {
const textArea = document.querySelector("textarea");
if (textArea) {
insertAtCursor(textArea, "this is regular text, **bold text**, *italic text*\n");
}
};
export const handleHeading2Click = (insertAtCursor: InsertAtCursor) => {
const textArea = document.querySelector("textarea");
if (textArea) {
insertAtCursor(textArea, "## Heading 2\n");
}
};
export const handleHeading3Click = (insertAtCursor: InsertAtCursor) => {
const textArea = document.querySelector("textarea");
if (textArea) {
insertAtCursor(textArea, "### Heading 3\n");
}
};
export const handleBulletListClick = (insertAtCursor: InsertAtCursor) => {
const textArea = document.querySelector("textarea");
if (textArea) {
insertAtCursor(textArea, "- List One\n- List Two\n- Other List\n");
}
};
export const handleNumberedListClick = (insertAtCursor: InsertAtCursor) => {
const textArea = document.querySelector("textarea");
if (textArea) {
insertAtCursor(textArea, "1. Number One\n2. Number Two\n3. Number Three\n");
}
};
export const handleLinkClick = (insertAtCursor: InsertAtCursor) => {
const textArea = document.querySelector("textarea");
if (textArea) {
insertAtCursor(textArea, "[Visit OpenAI](https://www.openai.com)\n");
}
};
export const handleImageClick = (insertAtCursor: InsertAtCursor) => {
const textArea = document.querySelector("textarea");
if (textArea) {
insertAtCursor(textArea, "![Alt text for the image](https://via.placeholder.com/150)\n");
}
};
export const handleBlockquoteClick = (insertAtCursor: InsertAtCursor) => {
const textArea = document.querySelector("textarea");
if (textArea) {
insertAtCursor(textArea, "> The overriding design goal for Markdown's formatting syntax is to make it as readable as possible.\n");
}
};
export const handleCodeBlockClick = (insertAtCursor: InsertAtCursor) => {
const textArea = document.querySelector("textarea");
if (textArea) {
insertAtCursor(
textArea,
"```javascript:main.js showLineNumbers {3-4}\nfunction isRocketAboutToCrash() {\n // Check if the rocket is stable\n if (!isStable()) {\n NoCrash(); // Prevent the crash\n }\n}\n```\n"
);
}
};
export const handleTableClick = (insertAtCursor: InsertAtCursor) => {
const textArea = document.querySelector("textarea");
if (textArea) {
insertAtCursor(
textArea,
`| **Feature** | **Description** |
| ------------------------------- | ----------------------------------------------------- |
| MDX Support | Write interactive documentation with MDX. |
| Nested Pages | Organize content in a nested, hierarchical structure. |
| Blog Section | Include a dedicated blog section. |
| Pagination | Split content across multiple pages. |
`
);
}
};
export const handleNoteClick = (insertAtCursor: InsertAtCursor, type: string) => {
return () => {
const textArea = document.querySelector("textarea");
if (textArea) {
const noteTemplate = `<Note type="${type}" title="${type.charAt(0).toUpperCase() + type.slice(1)}">\n This is a ${type} message.\n</Note>\n`;
insertAtCursor(textArea, noteTemplate);
}
};
};
export const handleComponentClick = (insertAtCursor: InsertAtCursor, component: string) => {
return () => {
const textArea = document.querySelector("textarea");
if (!textArea) return;
const templates: { [key: string]: string } = {
stepper: `<Stepper>
<StepperItem title="Step 1">
Content for step 1
</StepperItem>
<StepperItem title="Step 2">
Content for step 2
</StepperItem>
</Stepper>\n`,
card: `<Card title="Click on me" icon="Link" href="/docs/getting-started/components/button">
This is how you use a card with an icon and a link. Clicking on this card brings you to the Card Group page.
</Card>\n`,
button: `<Button
text="Click Me"
href="#"
icon="ArrowRight"
size="md"
variation="primary"
/>\n`,
accordion: `<Accordion title="Markdown">
this is an example of plain text content from the accordion component and below is markdown ;
1. number one
2. number two
3. number three
</Accordion>\n`,
youtube: `<Youtube videoId="your-video-id" />\n`,
tooltip: `What do you know about <Tooltip text="DocuBook" tip="npx @docubook/create@latest" /> ? Create interactive nested documentations using MDX.\n`,
tabs: `<Tabs defaultValue="tab1" className="pt-5 pb-1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
</TabsList>
<TabsContent value="tab1">
Content for tab 1
</TabsContent>
<TabsContent value="tab2">
Content for tab 2
</TabsContent>
</Tabs>\n`
};
insertAtCursor(textArea, templates[component]);
};
};
// slash command handler
export const MARK_COMPONENTS = [
{ label: "Metadata", value: `---
title: Post Title
description: Your Post Description
date: ${new Date().toISOString().split("T")[0]}
image: example-img.png
---\n\n` },
{ label: "Heading 2", value: "## Heading 2\n" },
{ label: "Heading 3", value: "### Heading 3\n" },
{ label: "Paragraph", value: "this is regular text, **bold text**, *italic text*\n" },
{ label: "Bullet List", value: "- List One\n- List Two\n- Other List\n" },
{ label: "Numbered List", value: "1. Number One\n2. Number Two\n3. Number Three\n" },
{ label: "Blockquote", value: "> The overriding design goal for Markdown's formatting syntax is to make it as readable as possible.\n" },
{ label: "Code Block", value: "```javascript:main.js showLineNumbers {3-4}\nfunction isRocketAboutToCrash() {\n // Check if the rocket is stable\n if (!isStable()) {\n NoCrash(); // Prevent the crash\n }\n}\n```\n" },
{ label: "Table", value: `| **Feature** | **Description** |
| ------------------------------- | ----------------------------------------------------- |
| MDX Support | Write interactive documentation with MDX. |
| Nested Pages | Organize content in a nested, hierarchical structure. |
| Blog Section | Include a dedicated blog section. |
| Pagination | Split content across multiple pages. |
` },
{ label: "Link", value: "[Visit OpenAI](https://www.openai.com)\n" },
{ label: "Image", value: "![Alt text for the image](https://via.placeholder.com/150)\n" },
// ⭐ Komponen Interaktif DocuBook ⭐
{ label: "Stepper", value: `<Stepper>
<StepperItem title="Step 1">
Content for step 1
</StepperItem>
<StepperItem title="Step 2">
Content for step 2
</StepperItem>
</Stepper>\n` },
{ label: "Button", value: `<Button
text="Click Me"
href="#"
icon="ArrowRight"
size="md"
variation="primary"
/>\n` },
{ label: "Accordion", value: `<Accordion title="Markdown">
This is an example of plain text content inside the accordion component.
1. Number One
2. Number Two
3. Number Three
</Accordion>\n` },
{ label: "Youtube", value: `<Youtube videoId="your-video-id" />\n` },
{ label: "Tooltip", value: `What do you know about <Tooltip text="DocuBook" tip="npx @docubook/create@latest" /> ?\n` },
{ label: "Tabs", value: `<Tabs defaultValue="tab1" className="pt-5 pb-1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
</TabsList>
<TabsContent value="tab1">
Content for tab 1
</TabsContent>
<TabsContent value="tab2">
Content for tab 2
</TabsContent>
</Tabs>\n` },
{ label: "Note", value: `<Note type="note" title="Note">
This is a note message.
</Note>\n` },
{ label: "Danger", value: `<Note type="danger" title="Danger">
This is a danger message.
</Note>\n` },
{ label: "Warning", value: `<Note type="warning" title="Warning">
This is a warning message.
</Note>\n` },
{ label: "Success", value: `<Note type="success" title="Success">
This is a success message.
</Note>\n` }
];

View File

@@ -1,52 +1,86 @@
"use client";
import { ArrowUpIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "./ui/button";
import { useEffect, useState, useCallback } from "react";
import Link from "next/link";
import { cn } from "@/lib/utils";
export function ScrollToTop() {
const [show, setShow] = useState(false);
interface ScrollToTopProps {
className?: string;
showIcon?: boolean;
offset?: number; // Optional offset in pixels from the trigger point
}
export function ScrollToTop({
className,
showIcon = true,
offset = 0
}: ScrollToTopProps) {
const [isVisible, setIsVisible] = useState(false);
const checkScroll = useCallback(() => {
// Calculate 50% of viewport height
const halfViewportHeight = window.innerHeight * 0.5;
// Check if scrolled past half viewport height (plus any offset)
const scrolledPastHalfViewport = window.scrollY > (halfViewportHeight + offset);
// Only update state if it changes to prevent unnecessary re-renders
if (scrolledPastHalfViewport !== isVisible) {
setIsVisible(scrolledPastHalfViewport);
}
}, [isVisible, offset]);
useEffect(() => {
const handleScroll = () => {
// Check if user has scrolled to bottom
const scrolledToBottom =
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 100;
// Initial check
checkScroll();
if (scrolledToBottom) {
setShow(true);
} else {
setShow(false);
}
// Set up scroll listener with debounce for better performance
let timeoutId: NodeJS.Timeout;
const handleScroll = () => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(checkScroll, 100);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
window.addEventListener('scroll', handleScroll, { passive: true });
// Cleanup
return () => {
window.removeEventListener('scroll', handleScroll);
if (timeoutId) clearTimeout(timeoutId);
};
}, [checkScroll]);
const scrollToTop = useCallback((e: React.MouseEvent) => {
e.preventDefault();
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}, []);
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
};
if (!isVisible) return null;
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"
"mt-4 pt-4 border-t border-stone-200 dark:border-stone-800",
"transition-opacity duration-300",
isVisible ? 'opacity-100' : 'opacity-0',
className
)}
>
<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>
<Link
href="#"
onClick={scrollToTop}
className={cn(
"inline-flex items-center text-sm text-muted-foreground hover:text-foreground",
"transition-all duration-200 hover:translate-y-[-1px]"
)}
aria-label="Scroll to top"
>
{showIcon && <ArrowUpIcon className="mr-1 h-3.5 w-3.5 flex-shrink-0" />}
<span>Scroll to Top</span>
</Link>
</div>
);
}

View File

@@ -12,6 +12,7 @@ import {
DialogTrigger,
DialogClose,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import Anchor from "./anchor";
import { advanceSearch, cn } from "@/lib/utils";
@@ -97,29 +98,40 @@ export default function Search() {
>
<DialogTrigger asChild>
<div className="relative flex-1 cursor-pointer max-w-[140px]">
<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-full 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-accent bg-accent text-white px-2 py-0.5 rounded-full">
<CommandIcon className="w-3 h-3" />
<span>K</span>
<div className="flex items-center">
<div className="md:hidden p-2 -ml-2">
<SearchIcon className="h-5 w-5 text-stone-500 dark:text-stone-400" />
</div>
<div className="hidden md:block w-full">
<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="w-full rounded-full 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-accent bg-accent text-white px-2 py-0.5 rounded-full">
<CommandIcon className="w-3 h-3" />
<span>K</span>
</div>
</div>
</div>
</div>
</DialogTrigger>
<DialogContent className="p-0 max-w-[650px] sm:top-[38%] top-[45%] !rounded-md">
<DialogTitle className="sr-only">Search</DialogTitle>
<DialogHeader>
<DialogTitle className="sr-only">Search Documentation</DialogTitle>
</DialogHeader>
<DialogDescription className="sr-only">
Search through the documentation
</DialogDescription>
<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"
className="h-14 px-6 bg-transparent border-b text-[14px] outline-none w-full"
aria-label="Search documentation"
/>
</DialogHeader>
{filteredResults.length == 0 && searchedInput && (
<p className="text-muted-foreground mx-auto mt-2 text-sm">
No results found for{" "}
@@ -149,11 +161,20 @@ export default function Search() {
>
<div
className={cn(
"flex items-center w-fit h-full py-3 gap-1.5 px-2",
"flex items-center w-full h-full py-3 gap-1.5 px-2 justify-between",
level > 1 && "border-l pl-4"
)}
>
<FileTextIcon className="h-[1.1rem] w-[1.1rem] mr-1" /> {item.title}
<div className="flex items-center">
<FileTextIcon className="h-[1.1rem] w-[1.1rem] mr-1" />
<span>{item.title}</span>
</div>
{isActive && (
<div className="hidden md:flex items-center text-xs text-muted-foreground">
<span>Return</span>
<CornerDownLeftIcon className="h-3 w-3 ml-1" />
</div>
)}
</div>
</Anchor>
</DialogClose>

View File

@@ -3,26 +3,62 @@
import { getDocsTocs } from "@/lib/markdown";
import clsx from "clsx";
import Link from "next/link";
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import { motion } from "framer-motion";
import { ScrollToTop } from "./scroll-to-top";
import { TocItem } from "@/lib/toc";
type Props = { data: Awaited<ReturnType<typeof getDocsTocs>> };
interface TocObserverProps {
data: TocItem[];
activeId?: string | null;
onActiveIdChange?: (id: string | null) => void;
}
export default function TocObserver({ data }: Props) {
const [activeId, setActiveId] = useState<string | null>(null);
export default function TocObserver({
data,
activeId: externalActiveId,
onActiveIdChange
}: TocObserverProps) {
const [internalActiveId, setInternalActiveId] = useState<string | null>(null);
const observer = useRef<IntersectionObserver | null>(null);
const [clickedId, setClickedId] = useState<string | null>(null);
const itemRefs = useRef<Map<string, HTMLAnchorElement>>(new Map());
// Use external activeId if provided, otherwise use internal state
const activeId = externalActiveId !== undefined ? externalActiveId : internalActiveId;
const setActiveId = onActiveIdChange || setInternalActiveId;
// Handle intersection observer for auto-highlighting
useEffect(() => {
const handleIntersect = (entries: IntersectionObserverEntry[]) => {
const visibleEntry = entries.find((entry) => entry.isIntersecting);
if (visibleEntry) {
setActiveId(visibleEntry.target.id);
const visibleEntries = entries.filter(entry => entry.isIntersecting);
// Find the most recently scrolled-into-view element
const mostVisibleEntry = visibleEntries.reduce((prev, current) => {
// Prefer the entry that's more visible or higher on the page
const prevRatio = prev?.intersectionRatio || 0;
const currentRatio = current.intersectionRatio;
if (currentRatio > prevRatio) return current;
if (currentRatio === prevRatio &&
current.boundingClientRect.top < prev.boundingClientRect.top) {
return current;
}
return prev;
}, visibleEntries[0]);
if (mostVisibleEntry && !clickedId) {
const newActiveId = mostVisibleEntry.target.id;
if (newActiveId !== activeId) {
setActiveId(newActiveId);
}
}
};
observer.current = new IntersectionObserver(handleIntersect, {
root: null,
rootMargin: "-20px 0px 0px 0px",
threshold: 0.1,
rootMargin: "-20% 0px -70% 0px", // Adjusted margins for better section detection
threshold: [0, 0.1, 0.5, 0.9, 1], // Multiple thresholds for better accuracy
});
const elements = data.map((item) =>
@@ -35,6 +71,11 @@ export default function TocObserver({ data }: Props) {
}
});
// Set initial active ID if none is set
if (!activeId && elements[0]) {
setActiveId(elements[0].id);
}
return () => {
if (observer.current) {
elements.forEach((el) => {
@@ -44,26 +85,180 @@ export default function TocObserver({ data }: Props) {
});
}
};
}, [data]);
}, [data, clickedId, activeId, setActiveId]);
const handleLinkClick = useCallback((id: string) => {
setClickedId(id);
setActiveId(id);
// Reset the clicked state after a delay to allow for smooth scrolling
const timer = setTimeout(() => {
setClickedId(null);
}, 1000);
return () => clearTimeout(timer);
}, [setActiveId]);
// Function to check if an item has children
const hasChildren = (currentId: string, currentLevel: number) => {
const currentIndex = data.findIndex(item => item.href.slice(1) === currentId);
if (currentIndex === -1 || currentIndex === data.length - 1) return false;
const nextItem = data[currentIndex + 1];
return nextItem.level > currentLevel;
};
// Calculate scroll progress for the active section
const [scrollProgress, setScrollProgress] = useState(0);
const [activeSectionIndex, setActiveSectionIndex] = useState(0);
useEffect(() => {
const handleScroll = () => {
if (!activeId) return;
const activeElement = document.getElementById(activeId);
if (!activeElement) return;
const rect = activeElement.getBoundingClientRect();
const windowHeight = window.innerHeight;
const elementTop = rect.top;
const elementHeight = rect.height;
// Calculate how much of the element is visible
let progress = 0;
if (elementTop < windowHeight) {
progress = Math.min(1, (windowHeight - elementTop) / (windowHeight + elementHeight));
}
setScrollProgress(progress);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [activeId]);
// Update active section index when activeId changes
useEffect(() => {
if (activeId) {
const index = data.findIndex(item => item.href.slice(1) === activeId);
if (index !== -1) {
setActiveSectionIndex(index);
}
}
}, [activeId, 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 className="relative">
<div className="relative text-sm text-stone-600 dark:text-stone-400">
<div className="flex flex-col gap-0">
{data.map(({ href, level, text }, index) => {
const id = href.slice(1);
const isActive = activeId === id;
const indent = level > 1 ? (level - 1) * 20 : 0;
const isParent = hasChildren(id, level);
const isLastInLevel = index === data.length - 1 || data[index + 1].level <= level;
return (
<div key={href} className="relative">
{/* Simple L-shaped connector */}
{level > 1 && (
<div
className={clsx("absolute top-0 h-full w-6", {
"left-[6px]": indent === 20, // Level 2
"left-[22px]": indent === 40, // Level 3
"left-[38px]": indent === 60, // Level 4
})}
>
{/* Vertical line */}
<div className={clsx(
"absolute left-0 top-0 h-full w-px",
isActive ? "bg-primary/20" : "bg-stone-300 dark:bg-stone-600"
)}>
{isActive && (
<motion.div
className="absolute left-0 top-0 w-full h-full bg-primary origin-top"
initial={{ scaleY: 0 }}
animate={{ scaleY: scrollProgress }}
transition={{ duration: 0.3 }}
/>
)}
</div>
{/* Horizontal line */}
<div className={clsx(
"absolute left-0 top-1/2 h-px w-6",
isActive ? "bg-primary/20" : "bg-stone-300 dark:bg-stone-600"
)}>
{isActive && (
<motion.div
className="absolute left-0 top-0 h-full w-full bg-primary origin-left"
initial={{ scaleX: 0 }}
animate={{ scaleX: scrollProgress }}
transition={{ duration: 0.3, delay: 0.1 }}
/>
)}
</div>
</div>
)}
<Link
href={href}
onClick={() => handleLinkClick(id)}
className={clsx(
"relative flex items-center py-2 transition-colors",
{
"text-primary dark:text-primary-400 font-medium": isActive,
"text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-stone-200": !isActive,
}
)}
style={{
paddingLeft: `${indent}px`,
marginLeft: level > 1 ? '12px' : '0',
}}
ref={(el) => {
const map = itemRefs.current;
if (el) {
map.set(id, el);
} else {
map.delete(id);
}
}}
>
{/* Circle indicator */}
<div className="relative w-4 h-4 flex items-center justify-center flex-shrink-0">
<div className={clsx(
"w-1.5 h-1.5 rounded-full transition-all duration-300 relative z-10",
{
"bg-primary scale-100": isActive,
"bg-stone-300 dark:bg-stone-600 scale-75 group-hover:scale-100 group-hover:bg-primary/50": !isActive,
}
)}>
{isActive && (
<motion.div
className="absolute inset-0 rounded-full bg-primary/20"
initial={{ scale: 1 }}
animate={{ scale: 1.8 }}
transition={{
duration: 2,
repeat: Infinity,
repeatType: "reverse"
}}
/>
)}
</div>
</div>
<span className="truncate text-sm">
{text}
</span>
</Link>
</div>
);
})}
</div>
</div>
{/* Add scroll to top link at the bottom of TOC */}
<ScrollToTop className="mt-6" />
</div>
);
}

View File

@@ -9,18 +9,18 @@ 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-6 w-full pl-2 h-full">
<div className="flex flex-col gap-3">
<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">
<div className="lg:flex hidden toc flex-[1.5] min-w-[238px] py-5 sticky top-16 h-[calc(100vh-4rem)]">
<div className="flex flex-col h-full w-full px-2 gap-2 mb-auto">
<div className="flex items-center gap-2">
<ListIcon className="w-4 h-4" />
<h3 className="font-medium text-sm">On this page</h3>
</div>
<div className="flex-shrink-0 min-h-0 max-h-[calc(70vh-4rem)]">
<ScrollArea className="h-full">
<TocObserver data={tocs} />
</ScrollArea>
</div>
<Sponsor />
<Sponsor />
</div>
</div>
);

View File

@@ -26,7 +26,7 @@ export const InteractiveHoverButton = React.forwardRef<
</div>
<div className="absolute top-0 z-10 flex h-full w-full translate-x-12 items-center justify-center gap-2 text-primary-foreground opacity-0 transition-all duration-300 group-hover:-translate-x-5 group-hover:opacity-100">
<span>{children}</span>
<ArrowRight />
<ArrowRight className="w-4 h-4" />
</div>
</button>
);