fix release version 1.11.0
This commit is contained in:
44
components/GithubStart.tsx
Normal file
44
components/GithubStart.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
107
components/markdown/ReleaseMdx.tsx
Normal file
107
components/markdown/ReleaseMdx.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
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 };
|
||||
|
||||
export default {
|
||||
Release,
|
||||
Changes
|
||||
};
|
||||
@@ -1,17 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { MDXProvider } from '@mdx-js/react';
|
||||
import { components } from './mdx-components';
|
||||
import { MDXRemote, MDXRemoteProps } from 'next-mdx-remote/rsc';
|
||||
import { Kbd } from './KeyboardMdx';
|
||||
|
||||
// Create a properly typed components object
|
||||
const typedComponents = {
|
||||
...components,
|
||||
// Add any default HTML elements you want to override
|
||||
// or keep their default behavior
|
||||
kbd: components.kbd as React.ComponentType<React.HTMLAttributes<HTMLElement>>,
|
||||
Kbd: components.Kbd as React.ComponentType<React.HTMLAttributes<HTMLElement> & { type?: 'window' | 'mac' }>,
|
||||
// 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' }>,
|
||||
};
|
||||
|
||||
export function MDXProviderWrapper({ children }: { children: React.ReactNode }) {
|
||||
return <MDXProvider components={typedComponents}>{children}</MDXProvider>;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
};
|
||||
}, [ref, callback]);
|
||||
};
|
||||
|
||||
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="md: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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -1,52 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUpIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import Anchor from "./anchor";
|
||||
import { advanceSearch, cn } from "@/lib/utils";
|
||||
@@ -110,16 +111,20 @@ export default function Search() {
|
||||
</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 +154,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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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-9 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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user