docu v 1.11.0
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -34,3 +34,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun.lock
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
**DocuBook** is a documentation web project designed to provide a simple and user-friendly interface for accessing various types of documentation. This site is crafted for developers and teams who need quick access to references, guides, and essential documents.
|
**DocuBook** is a documentation web project designed to provide a simple and user-friendly interface for accessing various types of documentation. This site is crafted for developers and teams who need quick access to references, guides, and essential documents.
|
||||||
|
|
||||||
> **Note**: This application is a fork of [AriaDocs](https://github.com/nisabmohd/Aria-Docs), created by [Nisab Mohd](https://github.com/nisabmohd). DocuBook provides an alternative to the documentation solution found on [Mintlify](https://mintlify.com/), utilizing `.mdx` (Markdown + JSX) for content creation and management.
|
|
||||||
|
|
||||||
[](https://vercel.com/import/project?template=https://github.com/gitfromwildan/docubook)
|
Vercel](https://vercel.com/button)](https://vercel.com/import/project?template=https://github.com/gitfromwildan/docubook)
|
||||||
|
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import { Typography } from "@/components/typography";
|
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
|
||||||
import { Author, getAllBlogStaticPaths, getBlogForSlug } from "@/lib/markdown";
|
|
||||||
import { ArrowLeftIcon } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { formatDate } from "@/lib/utils";
|
|
||||||
import { ScrollToTop } from "@/components/scroll-to-top";
|
|
||||||
|
|
||||||
type PageProps = {
|
|
||||||
params: { slug: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function generateMetadata({ params: { slug } }: PageProps) {
|
|
||||||
const res = await getBlogForSlug(slug);
|
|
||||||
if (!res) return null;
|
|
||||||
const { frontmatter } = res;
|
|
||||||
return {
|
|
||||||
title: frontmatter.title,
|
|
||||||
description: frontmatter.description,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
|
||||||
const val = await getAllBlogStaticPaths();
|
|
||||||
if (!val) return [];
|
|
||||||
return val.map((it) => ({ slug: it }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BlogPage({ params: { slug } }: PageProps) {
|
|
||||||
const res = await getBlogForSlug(slug);
|
|
||||||
if (!res) notFound();
|
|
||||||
return (
|
|
||||||
<div className="lg:w-[60%] sm:[95%] md:[75%] mx-auto">
|
|
||||||
<Link
|
|
||||||
className={buttonVariants({
|
|
||||||
variant: "link",
|
|
||||||
className: "!mx-0 !px-0 mb-7 !-ml-1 ",
|
|
||||||
})}
|
|
||||||
href="/blog"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="w-4 h-4 mr-1.5" /> Back to blog
|
|
||||||
</Link>
|
|
||||||
<div className="flex flex-col gap-3 pb-7 w-full mb-2">
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{formatDate(res.frontmatter.date)}
|
|
||||||
</p>
|
|
||||||
<h1 className="sm:text-4xl text-3xl font-extrabold">
|
|
||||||
{res.frontmatter.title}
|
|
||||||
</h1>
|
|
||||||
<div className="mt-6 flex flex-col gap-3">
|
|
||||||
<p className="text-sm text-muted-foreground">Posted by</p>
|
|
||||||
<Authors authors={res.frontmatter.authors} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="!w-full">
|
|
||||||
<Typography>{res.content}</Typography>
|
|
||||||
</div>
|
|
||||||
<ScrollToTop />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Authors({ authors }: { authors: Author[] }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-8 flex-wrap">
|
|
||||||
{authors.map((author) => {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={author.handleUrl}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
key={author.username}
|
|
||||||
>
|
|
||||||
<Avatar className="w-10 h-10">
|
|
||||||
<AvatarImage src={author.avatar} />
|
|
||||||
<AvatarFallback>
|
|
||||||
{author.username.slice(0, 2).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="">
|
|
||||||
<p className="text-sm font-medium">{author.username}</p>
|
|
||||||
<p className="font-code text-[13px] text-muted-foreground">
|
|
||||||
@{author.handle}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { PropsWithChildren } from "react";
|
|
||||||
|
|
||||||
export default function BlogLayout({ children }: PropsWithChildren) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-start justify-center pt-8 pb-10 w-full mx-auto">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { Author, BlogMdxFrontmatter, getAllBlogs } from "@/lib/markdown";
|
|
||||||
import { formatDate2, stringToDate } from "@/lib/utils";
|
|
||||||
import { getMetadata } from "@/app/layout";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { AuroraText } from "@/components/ui/aurora";
|
|
||||||
import { ShineBorder } from "@/components/ui/shine-border";
|
|
||||||
import docuConfig from "@/docu.json";
|
|
||||||
|
|
||||||
export const metadata = getMetadata({
|
|
||||||
title: "Blog",
|
|
||||||
description: "Discover the latest updates, tutorials, and insights on DocuBook.",
|
|
||||||
});
|
|
||||||
const { meta } = docuConfig;
|
|
||||||
export default async function BlogIndexPage() {
|
|
||||||
const blogs = (await getAllBlogs()).sort(
|
|
||||||
(a, b) => stringToDate(b.date).getTime() - stringToDate(a.date).getTime()
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center px-2 py-8 text-center sm:py-36">
|
|
||||||
<div className="w-full max-w-[800px] pb-8">
|
|
||||||
<AuroraText className="text-lg"># Stay Informed, Stay Ahead</AuroraText>
|
|
||||||
<h1 className="mb-4 text-2xl font-bold sm:text-5xl">
|
|
||||||
Blog Posts
|
|
||||||
</h1>
|
|
||||||
<p className="mb-8 sm:text-xl text-muted-foreground">
|
|
||||||
Explore updates, tips, and deep dives from the {meta.title}.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-left grid md:grid-cols-3 sm:grid-cols-2 grid-cols-1 sm:gap-8 gap-4 my-6">
|
|
||||||
{blogs.map((blog) => (
|
|
||||||
<BlogCard {...blog} slug={blog.slug} key={blog.slug} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BlogCard({
|
|
||||||
date,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
slug,
|
|
||||||
cover,
|
|
||||||
authors,
|
|
||||||
}: BlogMdxFrontmatter & { slug: string }) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={`/blog/${slug}`}
|
|
||||||
className="flex flex-col gap-2 items-start border rounded-md max-h-[420px] min-h-[420px]"
|
|
||||||
>
|
|
||||||
<div className="w-full">
|
|
||||||
<Image
|
|
||||||
src={cover}
|
|
||||||
alt={title}
|
|
||||||
width={400}
|
|
||||||
height={150}
|
|
||||||
quality={80}
|
|
||||||
className="w-full rounded-md object-cover h-[200px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-start px-3 py-3 gap-2 mb-auto">
|
|
||||||
<h3 className="text-md font-semibold line-clamp-2">{title}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-3">{description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between w-full px-3 mb-6">
|
|
||||||
<p className="text-[13px] text-muted-foreground">
|
|
||||||
Published on {formatDate2(date)}
|
|
||||||
</p>
|
|
||||||
<AvatarGroup users={authors} />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AvatarGroup({ users, max = 4 }: { users: Author[]; max?: number }) {
|
|
||||||
const displayUsers = users.slice(0, max);
|
|
||||||
const remainingUsers = Math.max(users.length - max, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center">
|
|
||||||
{displayUsers.map((user, index) => (
|
|
||||||
<Avatar
|
|
||||||
key={user.username}
|
|
||||||
className={`inline-block border-2 w-9 h-9 border-background ${
|
|
||||||
index !== 0 ? "-ml-3" : ""
|
|
||||||
} `}
|
|
||||||
>
|
|
||||||
<AvatarImage src={user.avatar} alt={user.username} />
|
|
||||||
<AvatarFallback>
|
|
||||||
{user.username.slice(0, 2).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
))}
|
|
||||||
{remainingUsers > 0 && (
|
|
||||||
<Avatar className="-ml-3 inline-block border-2 border-background hover:translate-y-1 transition-transform">
|
|
||||||
<AvatarFallback>+{remainingUsers}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export default function ChangelogLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-start gap-8">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { Suspense } from "react";
|
|
||||||
import { getChangelogEntries } from "@/lib/changelog";
|
|
||||||
import { VersionEntry } from "@/components/changelog/version-entry";
|
|
||||||
import { VersionToc } from "@/components/changelog/version-toc";
|
|
||||||
import { FloatingVersionToc } from "@/components/changelog/floating-version";
|
|
||||||
|
|
||||||
export default async function ChangelogPage() {
|
|
||||||
const entries = await getChangelogEntries();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-start">
|
|
||||||
<Suspense fallback={<div className="lg:flex hidden flex-[1.5]" />}>
|
|
||||||
<VersionToc
|
|
||||||
versions={entries.map(({ version, date }) => ({ version, date }))}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
<main className="flex-1 md:flex-[5.25] min-w-0 max-w-[800px]">
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute left-0 top-0 h-full w-px bg-border md:block hidden" />
|
|
||||||
<div className="md:px-12 md:py-8 max-md:py-10">
|
|
||||||
{entries.map((entry, index) => (
|
|
||||||
<section
|
|
||||||
id={`version-${entry.version}`}
|
|
||||||
key={entry.version}
|
|
||||||
className="scroll-mt-20" // Tambahkan margin atas saat scroll
|
|
||||||
>
|
|
||||||
<VersionEntry {...entry} isLast={index === entries.length - 1} />
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
{/* Floating TOC for smaller screens */}
|
|
||||||
{entries.length > 0 && (
|
|
||||||
<FloatingVersionToc
|
|
||||||
versions={entries.map(({ version, date }) => ({ version, date }))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import EditThisPage from "@/components/edit-on-github";
|
|||||||
import { formatDate2 } from "@/lib/utils";
|
import { formatDate2 } from "@/lib/utils";
|
||||||
import docuConfig from "@/docu.json";
|
import docuConfig from "@/docu.json";
|
||||||
import MobToc from "@/components/mob-toc";
|
import MobToc from "@/components/mob-toc";
|
||||||
import { ScrollToTop } from "@/components/scroll-to-top";
|
|
||||||
|
|
||||||
const { meta } = docuConfig;
|
const { meta } = docuConfig;
|
||||||
|
|
||||||
@@ -78,11 +77,9 @@ export default async function DocsPage({ params: { slug = [] } }: PageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-10">
|
<div className="flex items-start gap-10">
|
||||||
<div className="flex-[4.5] pt-10">
|
<div className="flex-[4.5] pt-5">
|
||||||
|
<MobToc tocs={tocs} />
|
||||||
<DocsBreadcrumb paths={slug} />
|
<DocsBreadcrumb paths={slug} />
|
||||||
<div className="mb-8">
|
|
||||||
<MobToc tocs={tocs} />
|
|
||||||
</div>
|
|
||||||
<Typography>
|
<Typography>
|
||||||
<h1 className="text-3xl !-mt-0.5">{title}</h1>
|
<h1 className="text-3xl !-mt-0.5">{title}</h1>
|
||||||
<p className="-mt-4 text-muted-foreground text-[16.5px]">{description}</p>
|
<p className="-mt-4 text-muted-foreground text-[16.5px]">{description}</p>
|
||||||
@@ -101,7 +98,6 @@ export default async function DocsPage({ params: { slug = [] } }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<Pagination pathname={pathName} />
|
<Pagination pathname={pathName} />
|
||||||
</Typography>
|
</Typography>
|
||||||
<ScrollToTop />
|
|
||||||
</div>
|
</div>
|
||||||
<Toc path={pathName} />
|
<Toc path={pathName} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ export default function DocsLayout({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-8">
|
<div className="flex items-start gap-8">
|
||||||
<Leftbar key="leftbar" />
|
<Leftbar key="leftbar" />
|
||||||
<div className="flex-[5.25]">{children}</div>
|
<div className="flex-[5.25] px-1">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { PropsWithChildren } from "react";
|
|
||||||
import { getMetadata } from "@/app/layout";
|
|
||||||
|
|
||||||
export const metadata = getMetadata({
|
|
||||||
title: "Playground",
|
|
||||||
description: "Test and experiment with DocuBook markdown components in real-time",
|
|
||||||
image: "img-playground.png",
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function PlaygroundLayout({ children }: PropsWithChildren) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col min-h-screen">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
|
||||||
List,
|
|
||||||
ListOrdered,
|
|
||||||
Heading2,
|
|
||||||
Heading3,
|
|
||||||
Code,
|
|
||||||
Quote,
|
|
||||||
ImageIcon,
|
|
||||||
Link as LinkIcon,
|
|
||||||
Table,
|
|
||||||
Maximize2,
|
|
||||||
Minimize2,
|
|
||||||
Type,
|
|
||||||
ChevronDown,
|
|
||||||
Notebook,
|
|
||||||
Component,
|
|
||||||
Youtube as YoutubeIcon,
|
|
||||||
HelpCircle,
|
|
||||||
LayoutGrid,
|
|
||||||
MousePointer2,
|
|
||||||
Rows,
|
|
||||||
LayoutPanelTop,
|
|
||||||
Laptop2,
|
|
||||||
Copy,
|
|
||||||
Download,
|
|
||||||
RotateCcw,
|
|
||||||
Calendar
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button as UIButton } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
handleParagraphClick,
|
|
||||||
handleHeading2Click,
|
|
||||||
handleHeading3Click,
|
|
||||||
handleBulletListClick,
|
|
||||||
handleNumberedListClick,
|
|
||||||
handleLinkClick,
|
|
||||||
handleImageClick,
|
|
||||||
handleBlockquoteClick,
|
|
||||||
handleCodeBlockClick,
|
|
||||||
handleTableClick,
|
|
||||||
handleNoteClick,
|
|
||||||
handleComponentClick,
|
|
||||||
handleMetadataClick,
|
|
||||||
} from "@/components/playground/MarkComponent";
|
|
||||||
|
|
||||||
import "@/styles/editor.css";
|
|
||||||
|
|
||||||
const ToolbarButton = ({ icon: Icon, label, onClick }: { icon: any, label: string, onClick?: () => void }) => (
|
|
||||||
<UIButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0 hover:bg-muted"
|
|
||||||
title={label}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
</UIButton>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ToolbarSeparator = () => (
|
|
||||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
|
||||||
);
|
|
||||||
|
|
||||||
const MobileMessage = () => (
|
|
||||||
<div className="min-h-[80vh] flex flex-col items-center justify-center text-center px-4 animate-in fade-in-50 duration-500">
|
|
||||||
<Laptop2 className="w-16 h-16 mb-4 text-muted-foreground animate-bounce" />
|
|
||||||
<h2 className="text-2xl font-bold mb-2">Desktop View Recommended</h2>
|
|
||||||
<p className="text-muted-foreground max-w-md">
|
|
||||||
The Playground works best on larger screens. Please switch to a desktop device for the best experience.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function PlaygroundPage() {
|
|
||||||
const [markdown, setMarkdown] = useState("");
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
|
||||||
const [lineCount, setLineCount] = useState(1);
|
|
||||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const lineNumbersRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkMobile = () => {
|
|
||||||
setIsMobile(window.innerWidth < 768);
|
|
||||||
};
|
|
||||||
|
|
||||||
checkMobile();
|
|
||||||
window.addEventListener('resize', checkMobile);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', checkMobile);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Update line count when markdown content changes
|
|
||||||
const lines = markdown.split('\n').length;
|
|
||||||
setLineCount(Math.max(lines, 1));
|
|
||||||
}, [markdown]);
|
|
||||||
|
|
||||||
// Sync scroll position between editor and line numbers
|
|
||||||
useEffect(() => {
|
|
||||||
const textarea = editorRef.current;
|
|
||||||
const lineNumbers = lineNumbersRef.current;
|
|
||||||
|
|
||||||
if (!textarea || !lineNumbers) return;
|
|
||||||
|
|
||||||
const handleScroll = () => {
|
|
||||||
lineNumbers.scrollTop = textarea.scrollTop;
|
|
||||||
};
|
|
||||||
|
|
||||||
textarea.addEventListener('scroll', handleScroll);
|
|
||||||
return () => textarea.removeEventListener('scroll', handleScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
|
||||||
setIsFullscreen(!isFullscreen);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(markdown);
|
|
||||||
toast.success('Content copied to clipboard');
|
|
||||||
} catch (err) {
|
|
||||||
toast.error('Failed to copy content');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = () => {
|
|
||||||
try {
|
|
||||||
const blob = new Blob([markdown], { type: 'text/markdown' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = 'index.mdx';
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
toast.success('Content downloaded successfully');
|
|
||||||
} catch (err) {
|
|
||||||
toast.error('Failed to download content');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
if (markdown.trim()) {
|
|
||||||
toast.custom((t) => (
|
|
||||||
<div className="flex flex-col gap-2 bg-background border rounded-lg p-4 shadow-lg">
|
|
||||||
<h3 className="font-semibold">Clear editor content?</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">This action cannot be undone.</p>
|
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
<UIButton
|
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => {
|
|
||||||
setMarkdown('');
|
|
||||||
toast.success('all content in the editor has been cleaned');
|
|
||||||
toast.dismiss(t);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</UIButton>
|
|
||||||
<UIButton
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => toast.dismiss(t)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</UIButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
), { duration: 10000 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const insertAtCursor = (textArea: HTMLTextAreaElement, text: string) => {
|
|
||||||
const start = textArea.selectionStart;
|
|
||||||
const end = textArea.selectionEnd;
|
|
||||||
const before = markdown.substring(0, start);
|
|
||||||
const after = markdown.substring(end);
|
|
||||||
|
|
||||||
const needsLeadingNewline = before && !before.endsWith('\n\n') ? '\n\n' : '';
|
|
||||||
const needsTrailingNewline = after && !after.startsWith('\n\n') ? '\n\n' : '';
|
|
||||||
|
|
||||||
const newText = `${before}${needsLeadingNewline}${text}${needsTrailingNewline}${after}`;
|
|
||||||
setMarkdown(newText);
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
textArea.focus();
|
|
||||||
const newPosition = before.length + needsLeadingNewline.length + text.length + 1;
|
|
||||||
textArea.setSelectionRange(newPosition, newPosition);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
return <MobileMessage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn(
|
|
||||||
"flex flex-col transition-all duration-200",
|
|
||||||
isFullscreen ? "fixed inset-0 z-50 bg-background" : "min-h-[calc(100vh-4rem)]"
|
|
||||||
)}>
|
|
||||||
<div className="border-b bg-background">
|
|
||||||
<div className="py-8 px-2">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h1 className="text-2xl font-extrabold">Docu<span className="text-primary text-lg ml-1">PLAY</span></h1>
|
|
||||||
<p className="text-lg text-muted-foreground mt-2">
|
|
||||||
Test and experiment with DocuBook markdown components in real-time
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 py-8 px-2">
|
|
||||||
<div className="flex flex-col h-full pb-12">
|
|
||||||
<ScrollArea className="flex-1 border rounded-lg">
|
|
||||||
<div className="sticky top-0 z-20 bg-background border-b">
|
|
||||||
<div className="flex items-center justify-between p-2 bg-muted/40">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{markdown.trim() && (
|
|
||||||
<>
|
|
||||||
<UIButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="gap-2 text-xs"
|
|
||||||
>
|
|
||||||
<Copy className="h-3.5 w-3.5" />
|
|
||||||
Copy
|
|
||||||
</UIButton>
|
|
||||||
<UIButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleDownload}
|
|
||||||
className="gap-2 text-xs"
|
|
||||||
>
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
Download
|
|
||||||
</UIButton>
|
|
||||||
<UIButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleReset}
|
|
||||||
className="gap-2 text-xs"
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-3.5 w-3.5" />
|
|
||||||
Reset
|
|
||||||
</UIButton>
|
|
||||||
<Separator orientation="vertical" className="h-4" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<UIButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={toggleFullscreen}
|
|
||||||
className="gap-2 text-xs"
|
|
||||||
>
|
|
||||||
{isFullscreen ? (
|
|
||||||
<>
|
|
||||||
<Minimize2 className="h-3.5 w-3.5" />
|
|
||||||
Exit Fullscreen
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Maximize2 className="h-3.5 w-3.5" />
|
|
||||||
Fullscreen
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</UIButton>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center border-b p-1 bg-background">
|
|
||||||
<ToolbarButton icon={Calendar} label="Metadata" onClick={() => handleMetadataClick(insertAtCursor)} />
|
|
||||||
<ToolbarSeparator />
|
|
||||||
<ToolbarButton icon={Type} label="Paragraph" onClick={() => handleParagraphClick(insertAtCursor)} />
|
|
||||||
<ToolbarButton icon={Heading2} label="Heading 2" onClick={() => handleHeading2Click(insertAtCursor)} />
|
|
||||||
<ToolbarButton icon={Heading3} label="Heading 3" onClick={() => handleHeading3Click(insertAtCursor)} />
|
|
||||||
<ToolbarButton icon={List} label="Bullet List" onClick={() => handleBulletListClick(insertAtCursor)} />
|
|
||||||
<ToolbarButton icon={ListOrdered} label="Numbered List" onClick={() => handleNumberedListClick(insertAtCursor)} />
|
|
||||||
<ToolbarSeparator />
|
|
||||||
<ToolbarButton icon={LinkIcon} label="Link" onClick={() => handleLinkClick(insertAtCursor)} />
|
|
||||||
<ToolbarButton icon={ImageIcon} label="Image" onClick={() => handleImageClick(insertAtCursor)} />
|
|
||||||
<ToolbarButton icon={Quote} label="Blockquote" onClick={() => handleBlockquoteClick(insertAtCursor)} />
|
|
||||||
<ToolbarButton icon={Code} label="Code Block" onClick={() => handleCodeBlockClick(insertAtCursor)} />
|
|
||||||
<ToolbarButton icon={Table} label="Table" onClick={() => handleTableClick(insertAtCursor)} />
|
|
||||||
<ToolbarSeparator />
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<UIButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 px-2 flex items-center gap-1 font-normal"
|
|
||||||
>
|
|
||||||
<Notebook className="h-4 w-4" />
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</UIButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
<DropdownMenuItem onClick={handleNoteClick(insertAtCursor, 'note')}>
|
|
||||||
Note
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleNoteClick(insertAtCursor, 'danger')}>
|
|
||||||
Danger
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleNoteClick(insertAtCursor, 'warning')}>
|
|
||||||
Warning
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleNoteClick(insertAtCursor, 'success')}>
|
|
||||||
Success
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<ToolbarSeparator />
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<UIButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 px-2 flex items-center gap-1 font-normal"
|
|
||||||
>
|
|
||||||
<Component className="h-4 w-4" />
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</UIButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
<DropdownMenuItem onClick={handleComponentClick(insertAtCursor, 'stepper')}>
|
|
||||||
<Rows className="h-4 w-4 mr-2" />
|
|
||||||
Stepper
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleComponentClick(insertAtCursor, 'card')}>
|
|
||||||
<LayoutGrid className="h-4 w-4 mr-2" />
|
|
||||||
Card
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleComponentClick(insertAtCursor, 'button')}>
|
|
||||||
<MousePointer2 className="h-4 w-4 mr-2" />
|
|
||||||
Button
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleComponentClick(insertAtCursor, 'accordion')}>
|
|
||||||
<ChevronDown className="h-4 w-4 mr-2" />
|
|
||||||
Accordion
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleComponentClick(insertAtCursor, 'tabs')}>
|
|
||||||
<LayoutPanelTop className="h-4 w-4 mr-2" />
|
|
||||||
Tabs
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleComponentClick(insertAtCursor, 'youtube')}>
|
|
||||||
<YoutubeIcon className="h-4 w-4 mr-2" />
|
|
||||||
Youtube
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleComponentClick(insertAtCursor, 'tooltip')}>
|
|
||||||
<HelpCircle className="h-4 w-4 mr-2" />
|
|
||||||
Tooltip
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="editor-container">
|
|
||||||
<div className="editor-line-numbers" ref={lineNumbersRef}>
|
|
||||||
<div className="editor-line-numbers-content">
|
|
||||||
{Array.from({ length: lineCount }).map((_, i) => (
|
|
||||||
<div key={i} data-line-number={i + 1} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
ref={editorRef}
|
|
||||||
value={markdown}
|
|
||||||
onChange={(e) => setMarkdown(e.target.value)}
|
|
||||||
className="editor-textarea"
|
|
||||||
spellCheck={false}
|
|
||||||
placeholder="Type '/' for commands..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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,37 +1,78 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import Link from "next/link";
|
import Link, { LinkProps } from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { ComponentProps, forwardRef } from "react";
|
import { forwardRef } from "react";
|
||||||
|
|
||||||
type AnchorProps = ComponentProps<typeof Link> & {
|
type AnchorProps = LinkProps & {
|
||||||
absolute?: boolean;
|
absolute?: boolean;
|
||||||
activeClassName?: string;
|
activeClassName?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
} & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps>;
|
||||||
|
|
||||||
const Anchor = forwardRef<HTMLAnchorElement, AnchorProps>(
|
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 path = usePathname();
|
||||||
const href = props.href.toString();
|
const hrefStr = href?.toString() || '';
|
||||||
|
|
||||||
// Deteksi URL eksternal menggunakan regex
|
// Check if URL is external
|
||||||
const isExternal = /^(https?:\/\/|\/\/)/.test(href);
|
const isExternal = /^(https?:\/\/|\/\/)/.test(hrefStr);
|
||||||
|
|
||||||
let isMatch = absolute
|
// Check if current path matches the link
|
||||||
? href.split("/")[1] === path.split("/")[1]
|
const isActive = absolute
|
||||||
: path === href;
|
? 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 (
|
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 (
|
return (
|
||||||
<Link ref={ref} className={cn(className, isMatch && activeClassName)} {...props}>
|
<Link
|
||||||
|
ref={ref}
|
||||||
|
href={hrefStr}
|
||||||
|
className={linkClassName}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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[] }) {
|
export default function DocsBreadcrumb({ paths }: { paths: string[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="pb-5">
|
<div className="pb-5 max-lg:pt-12">
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
|
|||||||
@@ -15,44 +15,26 @@ interface FooterConfig {
|
|||||||
social?: SocialItem[];
|
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
|
// Type assertion for docu.json
|
||||||
const docuConfig = docuData as DocuConfig;
|
const docuConfig = docuData as {
|
||||||
|
footer: FooterConfig;
|
||||||
|
};
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const { footer } = docuConfig;
|
const { footer } = docuConfig;
|
||||||
const { meta } = docuConfig;
|
|
||||||
return (
|
return (
|
||||||
<footer className="w-full py-4 px-2 border-t lg:py-8 bg-background">
|
<footer className="w-full py-8 border-t bg-background">
|
||||||
<div className="container flex flex-wrap items-center justify-between text-sm">
|
<div className="container flex flex-col lg:flex-row items-center justify-between text-sm">
|
||||||
<div className="items-start justify-center hidden gap-4 lg:flex-col lg:flex lg:w-3/5">
|
<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">
|
||||||
<h3 className="text-lg font-bold font-code">{meta.title}</h3>
|
<p className="text-muted-foreground">
|
||||||
<span className="w-3/4 text-base text-wrap text-muted-foreground">{meta.description}</span>
|
Copyright © {new Date().getFullYear()} {footer.copyright} - <MadeWith />
|
||||||
<div className="flex items-center gap-6 mt-2">
|
</p>
|
||||||
|
<div className="flex items-center justify-center lg:justify-start gap-6 mt-2 w-full">
|
||||||
<FooterButtons />
|
<FooterButtons />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center justify-center w-full gap-4 mt-4 lg:items-end lg:w-2/5">
|
<div className="hidden lg:flex items-center justify-end lg:w-2/5">
|
||||||
<p className="text-center text-muted-foreground">
|
<ModeToggle />
|
||||||
Copyright © {new Date().getFullYear()} {footer.copyright} - <MadeWith />
|
|
||||||
</p>
|
|
||||||
<div className="hidden lg:flex">
|
|
||||||
<ModeToggle />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -11,37 +11,49 @@ import { Logo, NavMenu } from "@/components/navbar";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AlignLeftIcon, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
import { AlignLeftIcon, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||||
import { FooterButtons } from "@/components/footer";
|
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 { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import DocsMenu from "@/components/docs-menu";
|
import DocsMenu from "@/components/docs-menu";
|
||||||
import { ModeToggle } from "@/components/theme-toggle";
|
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() {
|
export function Leftbar() {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const toggleCollapse = () => setCollapsed(prev => !prev);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={`sticky lg:flex hidden top-16 h-[calc(100vh-4rem)] border-r bg-background transition-all duration-300
|
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 */}
|
<ToggleButton collapsed={collapsed} onToggle={toggleCollapse} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Scrollable DocsMenu */}
|
{/* Scrollable DocsMenu */}
|
||||||
<ScrollArea className="flex-1 px-2 pb-4">
|
<ScrollArea className="flex-1 px-0.5 pb-4">
|
||||||
{!collapsed && <DocsMenu />}
|
{!collapsed && <DocsMenu />}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -57,7 +69,10 @@ export function SheetLeftbar() {
|
|||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent className="flex flex-col gap-4 px-0" side="left">
|
<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>
|
<SheetHeader>
|
||||||
<SheetClose className="px-5" asChild>
|
<SheetClose className="px-5" asChild>
|
||||||
<span className="px-2"><Logo /></span>
|
<span className="px-2"><Logo /></span>
|
||||||
@@ -70,9 +85,6 @@ export function SheetLeftbar() {
|
|||||||
<div className="mx-2 px-5">
|
<div className="mx-2 px-5">
|
||||||
<DocsMenu isSheet />
|
<DocsMenu isSheet />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-2 flex justify-start items-center gap-6">
|
|
||||||
<FooterButtons />
|
|
||||||
</div>
|
|
||||||
<div className="flex w-2/4 px-5">
|
<div className="flex w-2/4 px-5">
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
102
components/markdown/KeyboardMdx.tsx
Normal file
102
components/markdown/KeyboardMdx.tsx
Normal 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;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ComponentProps } from "react";
|
import { ComponentProps } from "react";
|
||||||
import Copy from "./copy";
|
import Copy from "./CopyMdx";
|
||||||
|
|
||||||
export default function Pre({
|
export default function Pre({
|
||||||
children,
|
children,
|
||||||
109
components/markdown/ReleaseMdx.tsx
Normal file
109
components/markdown/ReleaseMdx.tsx
Normal 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;
|
||||||
29
components/markdown/mdx-provider.tsx
Normal file
29
components/markdown/mdx-provider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,38 +1,128 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ListIcon } from "lucide-react";
|
import { List, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import TocObserver from "./toc-observer";
|
import TocObserver from "./toc-observer";
|
||||||
import {
|
import * as React from "react";
|
||||||
Accordion,
|
import { useRef, useMemo } from "react";
|
||||||
AccordionContent,
|
import { usePathname } from "next/navigation";
|
||||||
AccordionItem,
|
import { Button } from "./ui/button";
|
||||||
AccordionTrigger,
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
} from "@/components/ui/accordion";
|
import { useScrollPosition, useActiveSection } from "@/hooks";
|
||||||
|
import { TocItem } from "@/lib/toc";
|
||||||
|
|
||||||
interface MobTocProps {
|
interface MobTocProps {
|
||||||
tocs: {
|
tocs: TocItem[];
|
||||||
level: number;
|
|
||||||
text: string;
|
|
||||||
href: string;
|
|
||||||
}[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
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 (
|
return (
|
||||||
<div className="lg:hidden block w-full">
|
<AnimatePresence>
|
||||||
<Accordion type="single" collapsible>
|
<motion.div
|
||||||
<AccordionItem value="toc">
|
ref={tocRef}
|
||||||
<AccordionTrigger className="hover:no-underline">
|
className="lg:hidden fixed top-16 left-0 right-0 z-50"
|
||||||
<div className="flex items-center gap-2">
|
initial={{ y: -100, opacity: 0 }}
|
||||||
<ListIcon className="w-4 h-4" />
|
animate={{ y: 0, opacity: 1 }}
|
||||||
<span className="font-medium text-sm">On this page</span>
|
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>
|
</div>
|
||||||
</AccordionTrigger>
|
</div>
|
||||||
<AccordionContent className="h-auto py-2">
|
</motion.div>
|
||||||
<TocObserver data={tocs} />
|
</AnimatePresence>
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,14 +34,22 @@ export function Navbar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Logo() {
|
export function Logo() {
|
||||||
const { navbar } = docuConfig; // Extract navbar from JSON
|
const { navbar } = docuConfig; // Extract navbar from JSON
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href="/" className="flex items-center gap-1.5">
|
<Link href="/" className="flex items-center gap-1.5">
|
||||||
<Image src={navbar.logo.src} alt={navbar.logo.alt} width="24" height="24" />
|
<div className="relative w-8 h-8">
|
||||||
<h2 className="font-bold font-code text-md">{navbar.logoText}</h2>
|
<Image
|
||||||
</Link>
|
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 }) {
|
export function NavMenu({ isSheet = false }) {
|
||||||
|
|||||||
@@ -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, "\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: "\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` }
|
|
||||||
|
|
||||||
];
|
|
||||||
@@ -1,52 +1,86 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowUpIcon } from "lucide-react";
|
import { ArrowUpIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { Button } from "./ui/button";
|
import Link from "next/link";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function ScrollToTop() {
|
interface ScrollToTopProps {
|
||||||
const [show, setShow] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
// Initial check
|
||||||
// Check if user has scrolled to bottom
|
checkScroll();
|
||||||
const scrolledToBottom =
|
|
||||||
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 100;
|
|
||||||
|
|
||||||
if (scrolledToBottom) {
|
// Set up scroll listener with debounce for better performance
|
||||||
setShow(true);
|
let timeoutId: NodeJS.Timeout;
|
||||||
} else {
|
const handleScroll = () => {
|
||||||
setShow(false);
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
}
|
timeoutId = setTimeout(checkScroll, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("scroll", handleScroll);
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
|
||||||
|
// 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 = () => {
|
if (!isVisible) return null;
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"lg:hidden fixed top-16 items-center z-50 w-full transition-all duration-300",
|
"mt-4 pt-4 border-t border-stone-200 dark:border-stone-800",
|
||||||
show ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-full pointer-events-none"
|
"transition-opacity duration-300",
|
||||||
|
isVisible ? 'opacity-100' : 'opacity-0',
|
||||||
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex justify-center items-center pt-3 mx-auto">
|
<Link
|
||||||
<Button
|
href="#"
|
||||||
variant="outline"
|
onClick={scrollToTop}
|
||||||
size="sm"
|
className={cn(
|
||||||
className="gap-2 rounded-full shadow-md bg-background/80 backdrop-blur-sm border-primary/20 hover:bg-background hover:text-primary"
|
"inline-flex items-center text-sm text-muted-foreground hover:text-foreground",
|
||||||
onClick={scrollToTop}
|
"transition-all duration-200 hover:translate-y-[-1px]"
|
||||||
>
|
)}
|
||||||
<ArrowUpIcon className="h-4 w-4" />
|
aria-label="Scroll to top"
|
||||||
<span className="font-medium">Scroll to Top</span>
|
>
|
||||||
</Button>
|
{showIcon && <ArrowUpIcon className="mr-1 h-3.5 w-3.5 flex-shrink-0" />}
|
||||||
</div>
|
<span>Scroll to Top</span>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import Anchor from "./anchor";
|
import Anchor from "./anchor";
|
||||||
import { advanceSearch, cn } from "@/lib/utils";
|
import { advanceSearch, cn } from "@/lib/utils";
|
||||||
@@ -97,29 +98,40 @@ export default function Search() {
|
|||||||
>
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<div className="relative flex-1 cursor-pointer max-w-[140px]">
|
<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" />
|
<div className="flex items-center">
|
||||||
<Input
|
<div className="md:hidden p-2 -ml-2">
|
||||||
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"
|
<SearchIcon className="h-5 w-5 text-stone-500 dark:text-stone-400" />
|
||||||
placeholder="Search"
|
</div>
|
||||||
type="search"
|
<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" />
|
||||||
<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">
|
<Input
|
||||||
<CommandIcon className="w-3 h-3" />
|
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"
|
||||||
<span>K</span>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="p-0 max-w-[650px] sm:top-[38%] top-[45%] !rounded-md">
|
<DialogContent className="p-0 max-w-[650px] sm:top-[38%] top-[45%] !rounded-md">
|
||||||
<DialogTitle className="sr-only">Search</DialogTitle>
|
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
<DialogTitle className="sr-only">Search Documentation</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
Search through the documentation
|
||||||
|
</DialogDescription>
|
||||||
<input
|
<input
|
||||||
value={searchedInput}
|
value={searchedInput}
|
||||||
onChange={(e) => setSearchedInput(e.target.value)}
|
onChange={(e) => setSearchedInput(e.target.value)}
|
||||||
placeholder="Type something to search..."
|
placeholder="Type something to search..."
|
||||||
autoFocus
|
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 && (
|
{filteredResults.length == 0 && searchedInput && (
|
||||||
<p className="text-muted-foreground mx-auto mt-2 text-sm">
|
<p className="text-muted-foreground mx-auto mt-2 text-sm">
|
||||||
No results found for{" "}
|
No results found for{" "}
|
||||||
@@ -149,11 +161,20 @@ export default function Search() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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"
|
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>
|
</div>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
|
|||||||
@@ -3,26 +3,62 @@
|
|||||||
import { getDocsTocs } from "@/lib/markdown";
|
import { getDocsTocs } from "@/lib/markdown";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Link from "next/link";
|
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) {
|
export default function TocObserver({
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
data,
|
||||||
|
activeId: externalActiveId,
|
||||||
|
onActiveIdChange
|
||||||
|
}: TocObserverProps) {
|
||||||
|
const [internalActiveId, setInternalActiveId] = useState<string | null>(null);
|
||||||
const observer = useRef<IntersectionObserver | 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(() => {
|
useEffect(() => {
|
||||||
const handleIntersect = (entries: IntersectionObserverEntry[]) => {
|
const handleIntersect = (entries: IntersectionObserverEntry[]) => {
|
||||||
const visibleEntry = entries.find((entry) => entry.isIntersecting);
|
const visibleEntries = entries.filter(entry => entry.isIntersecting);
|
||||||
if (visibleEntry) {
|
|
||||||
setActiveId(visibleEntry.target.id);
|
// 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, {
|
observer.current = new IntersectionObserver(handleIntersect, {
|
||||||
root: null,
|
root: null,
|
||||||
rootMargin: "-20px 0px 0px 0px",
|
rootMargin: "-20% 0px -70% 0px", // Adjusted margins for better section detection
|
||||||
threshold: 0.1,
|
threshold: [0, 0.1, 0.5, 0.9, 1], // Multiple thresholds for better accuracy
|
||||||
});
|
});
|
||||||
|
|
||||||
const elements = data.map((item) =>
|
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 () => {
|
return () => {
|
||||||
if (observer.current) {
|
if (observer.current) {
|
||||||
elements.forEach((el) => {
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-2.5 text-sm dark:text-stone-300/85 text-stone-800 ml-0.5">
|
<div className="relative">
|
||||||
{data.map(({ href, level, text }) => {
|
<div className="relative text-sm text-stone-600 dark:text-stone-400">
|
||||||
return (
|
<div className="flex flex-col gap-0">
|
||||||
<Link
|
{data.map(({ href, level, text }, index) => {
|
||||||
key={href}
|
const id = href.slice(1);
|
||||||
href={href}
|
const isActive = activeId === id;
|
||||||
className={clsx({
|
const indent = level > 1 ? (level - 1) * 20 : 0;
|
||||||
"pl-0": level == 2,
|
const isParent = hasChildren(id, level);
|
||||||
"pl-4": level == 3,
|
const isLastInLevel = index === data.length - 1 || data[index + 1].level <= level;
|
||||||
"pl-8 ": level == 4,
|
|
||||||
"font-medium text-primary": activeId == href.slice(1),
|
return (
|
||||||
})}
|
<div key={href} className="relative">
|
||||||
>
|
{/* Simple L-shaped connector */}
|
||||||
{text}
|
{level > 1 && (
|
||||||
</Link>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,18 +9,18 @@ export default async function Toc({ path }: { path: string }) {
|
|||||||
const tocs = await getDocsTocs(path);
|
const tocs = await getDocsTocs(path);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="lg:flex hidden toc flex-[1.5] min-w-[238px] py-9 sticky top-16 h-[96.95vh]">
|
<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 gap-6 w-full pl-2 h-full">
|
<div className="flex flex-col h-full w-full px-2 gap-2 mb-auto">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<ListIcon className="w-4 h-4" />
|
||||||
<ListIcon className="w-5 h-5" />
|
<h3 className="font-medium text-sm">On this page</h3>
|
||||||
<h3 className="font-medium text-sm">On this page</h3>
|
</div>
|
||||||
</div>
|
<div className="flex-shrink-0 min-h-0 max-h-[calc(70vh-4rem)]">
|
||||||
<ScrollArea className="pb-2 pt-0.5 overflow-y-auto">
|
<ScrollArea className="h-full">
|
||||||
<TocObserver data={tocs} />
|
<TocObserver data={tocs} />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
<Sponsor />
|
<Sponsor />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const InteractiveHoverButton = React.forwardRef<
|
|||||||
</div>
|
</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">
|
<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>
|
<span>{children}</span>
|
||||||
<ArrowRight />
|
<ArrowRight className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Getting Started with DocuBook Components"
|
|
||||||
description: "Learn how to leverage the power of DocuBook components to create dynamic and interactive documentation. This guide explores the key components available and how to integrate them into your documentation workflow."
|
|
||||||
date: 31-12-2024
|
|
||||||
authors:
|
|
||||||
- avatar: "https://ui.shadcn.com/avatars/02.png"
|
|
||||||
handle: mywildancloud
|
|
||||||
username: Wildan nrs
|
|
||||||
handleUrl: "https://github.com/mywildancloud"
|
|
||||||
cover: "https://img.freepik.com/free-vector/spring-landscape-scene_23-2148860692.jpg?t=st=1735654206~exp=1735657806~hmac=b65033387b5519b48c72a87333cf1a5d2462de255865104c612500161b248a8a&w=2000"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Introduction to DocuBook Components
|
|
||||||
|
|
||||||
DocuBook provides a robust set of components that enable developers to build dynamic, user-friendly, and visually appealing documentation. These components are designed to enhance the user experience, making it easier for readers to navigate, understand, and interact with the content.
|
|
||||||
|
|
||||||
In this guide, we’ll explore the core components available in DocuBook and how you can integrate them into your documentation projects. For a complete list of components, visit the [official documentation](https://www.docubook.pro/docs/getting-started/components).
|
|
||||||
|
|
||||||
## Key Components and Their Usage
|
|
||||||
|
|
||||||
### 1. **Stepper**
|
|
||||||
|
|
||||||
#### Preview
|
|
||||||
|
|
||||||
<Stepper>
|
|
||||||
<StepperItem title="Step 1: Clone the DocuBook Repository">
|
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec interdum,
|
|
||||||
felis sed efficitur tincidunt, justo nulla viverra enim, et maximus nunc
|
|
||||||
dolor in lorem.
|
|
||||||
</StepperItem>
|
|
||||||
<StepperItem title="Step 2: Access the Project Directory">
|
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin non neque ut
|
|
||||||
eros auctor accumsan. Mauris a nisl vitae magna ultricies aliquam.
|
|
||||||
</StepperItem>
|
|
||||||
<StepperItem title="Step 3: Install Required Dependencies">
|
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque ut
|
|
||||||
ipsum nec nulla ultricies porttitor et non justo.
|
|
||||||
</StepperItem>
|
|
||||||
</Stepper>
|
|
||||||
|
|
||||||
#### Code
|
|
||||||
|
|
||||||
```
|
|
||||||
<Stepper>
|
|
||||||
<StepperItem title="Step 1: Clone the DocuBook Repository">
|
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec interdum,
|
|
||||||
felis sed efficitur tincidunt, justo nulla viverra enim, et maximus nunc
|
|
||||||
dolor in lorem.
|
|
||||||
</StepperItem>
|
|
||||||
<StepperItem title="Step 2: Access the Project Directory">
|
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin non neque ut
|
|
||||||
eros auctor accumsan. Mauris a nisl vitae magna ultricies aliquam.
|
|
||||||
</StepperItem>
|
|
||||||
<StepperItem title="Step 3: Install Required Dependencies">
|
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque ut
|
|
||||||
ipsum nec nulla ultricies porttitor et non justo.
|
|
||||||
</StepperItem>
|
|
||||||
</Stepper>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **Note**
|
|
||||||
|
|
||||||
#### Preview
|
|
||||||
|
|
||||||
<Note type="note" title="Note">
|
|
||||||
This is a general note to convey information to the user.
|
|
||||||
</Note>
|
|
||||||
<Note type="danger" title="Danger">
|
|
||||||
This is a danger alert to notify the user of a critical issue.
|
|
||||||
</Note>
|
|
||||||
<Note type="warning" title="Warning">
|
|
||||||
This is a warning alert for issues that require attention.
|
|
||||||
</Note>
|
|
||||||
<Note type="success" title="Success">
|
|
||||||
This is a success message to inform the user of successful actions.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
#### Code
|
|
||||||
|
|
||||||
```
|
|
||||||
<Note type="note" title="Note">
|
|
||||||
This is a general note to convey information to the user.
|
|
||||||
</Note>
|
|
||||||
<Note type="danger" title="Danger">
|
|
||||||
This is a danger alert to notify the user of a critical issue.
|
|
||||||
</Note>
|
|
||||||
<Note type="warning" title="Warning">
|
|
||||||
This is a warning alert for issues that require attention.
|
|
||||||
</Note>
|
|
||||||
<Note type="success" title="Success">
|
|
||||||
This is a success message to inform the user of successful actions.
|
|
||||||
</Note>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **Code Block**
|
|
||||||
|
|
||||||
#### Preview
|
|
||||||
|
|
||||||
```javascript:main.js showLineNumbers {3-4}
|
|
||||||
function isRocketAboutToCrash() {
|
|
||||||
// Check if the rocket is stable
|
|
||||||
if (!isStable()) {
|
|
||||||
NoCrash(); // Prevent the crash
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Code
|
|
||||||
|
|
||||||
````plaintext
|
|
||||||
```javascript:main.js showLineNumbers {3-4}
|
|
||||||
function isRocketAboutToCrash() {
|
|
||||||
// Check if the rocket is stable
|
|
||||||
if (!isStable()) {
|
|
||||||
NoCrash(); // Prevent the crash
|
|
||||||
}
|
|
||||||
}```
|
|
||||||
````
|
|
||||||
|
|
||||||
## How to Integrate Components into Your Workflow
|
|
||||||
|
|
||||||
1. **Install DocuBook**: Ensure you have DocuBook set up in your project. Refer to the [installation guide](https://www.docubook.pro/docs/getting-started).
|
|
||||||
2. **Import Components**: Import the required components into your MDX files.
|
|
||||||
3. **Customize**: Tailor the components to fit your documentation needs using props and styles.
|
|
||||||
4. **Test and Deploy**: Preview your documentation locally and deploy it to your preferred hosting platform.
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
DocuBook components are powerful tools for creating engaging and functional documentation. By incorporating these components, you can provide a seamless and intuitive experience for your users.
|
|
||||||
|
|
||||||
Ready to get started? Explore the full range of components in the [DocuBook documentation](https://www.docubook.pro/docs/getting-started/components) and elevate your documentation today!
|
|
||||||
|
|
||||||
🚀📚
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Building a Recursive File System with React: A Deep Dive"
|
|
||||||
description: "Explore how to create a recursive file system in React. This blog post provides a comprehensive guide on building a file system where folders and files can be nested, added, renamed, and deleted."
|
|
||||||
date: 02-09-2024
|
|
||||||
authors:
|
|
||||||
- avatar: "https://ui.shadcn.com/avatars/02.png"
|
|
||||||
handle: nisabmohd
|
|
||||||
username: Nisab Mohd
|
|
||||||
handleUrl: "https://github.com/nisabmohd"
|
|
||||||
cover: "https://img.freepik.com/premium-vector/many-monsters-various-colors-doodle-come-bless-birthday-happy_577083-84.jpg?w=826"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Introduction: Crafting a Recursive File System in React
|
|
||||||
|
|
||||||
In modern web development, creating interactive and dynamic file systems is a common requirement. Whether for managing documents, organizing projects, or building complex data structures, having a robust file system is crucial. In this blog post, we’ll explore how to build a recursive file system in React, focusing on nested folders and files that can be added, renamed, or deleted.
|
|
||||||
|
|
||||||
Check out the project on [GitHub](https://github.com/nisabmohd/recursive-file-system-react) for a complete implementation.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
The Recursive File System project is designed to simulate a file management system where users can interact with folders and files dynamically. It supports the following features:
|
|
||||||
|
|
||||||
- **Adding New Folders and Files**: Create new folders and files within any existing folder.
|
|
||||||
- **Renaming Items**: Change the name of folders and files.
|
|
||||||
- **Deleting Items**: Remove folders and files from the file system.
|
|
||||||
- **Nested Structure**: Handle nested folders and files to create a hierarchical view.
|
|
||||||
|
|
||||||
## Key Features and Implementation
|
|
||||||
|
|
||||||
### 1. Recursive Data Structure
|
|
||||||
|
|
||||||
The core of the project is a recursive data structure that represents the file system. Each folder can contain other folders or files, and each file or folder has properties such as `id`, `name`, and `children` (for folders).
|
|
||||||
|
|
||||||
Here’s a basic structure for a folder:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
const folder = {
|
|
||||||
id: "1",
|
|
||||||
name: "Documents",
|
|
||||||
type: "folder",
|
|
||||||
children: [
|
|
||||||
{ id: "2", name: "Resume.pdf", type: "file" },
|
|
||||||
{ id: "3", name: "CoverLetter.docx", type: "file" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Components
|
|
||||||
|
|
||||||
The project includes several key components to handle different aspects of the file system:
|
|
||||||
|
|
||||||
- **FileExplorer**: Displays the entire file system and handles rendering folders and files.
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// src/components/FileExplorer.js
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import Folder from "./Folder";
|
|
||||||
import File from "./File";
|
|
||||||
|
|
||||||
const FileExplorer = () => {
|
|
||||||
const [files, setFiles] = useState(initialData); // initialData is your recursive data structure
|
|
||||||
|
|
||||||
const addItem = (parentId, type) => {
|
|
||||||
// Logic to add a folder or file
|
|
||||||
};
|
|
||||||
|
|
||||||
const renameItem = (id, newName) => {
|
|
||||||
// Logic to rename a folder or file
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteItem = (id) => {
|
|
||||||
// Logic to delete a folder or file
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{files.map((file) =>
|
|
||||||
file.type === "folder" ? (
|
|
||||||
<Folder
|
|
||||||
key={file.id}
|
|
||||||
folder={file}
|
|
||||||
onAdd={addItem}
|
|
||||||
onRename={renameItem}
|
|
||||||
onDelete={deleteItem}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<File
|
|
||||||
key={file.id}
|
|
||||||
file={file}
|
|
||||||
onRename={renameItem}
|
|
||||||
onDelete={deleteItem}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FileExplorer;
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Folder**: Renders folders and handles nested items.
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// src/components/Folder.js
|
|
||||||
import React from "react";
|
|
||||||
import FileExplorer from "./FileExplorer";
|
|
||||||
|
|
||||||
const Folder = ({ folder, onAdd, onRename, onDelete }) => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3>{folder.name}</h3>
|
|
||||||
<button onClick={() => onAdd(folder.id, "folder")}>Add Folder</button>
|
|
||||||
<button onClick={() => onAdd(folder.id, "file")}>Add File</button>
|
|
||||||
<button onClick={() => onRename(folder.id, "New Name")}>Rename</button>
|
|
||||||
<button onClick={() => onDelete(folder.id)}>Delete</button>
|
|
||||||
{folder.children && <FileExplorer files={folder.children} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Folder;
|
|
||||||
```
|
|
||||||
|
|
||||||
- **File**: Renders individual files with options to rename and delete.
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// src/components/File.js
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
const File = ({ file, onRename, onDelete }) => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p>{file.name}</p>
|
|
||||||
<button onClick={() => onRename(file.id, "New Name")}>Rename</button>
|
|
||||||
<button onClick={() => onDelete(file.id)}>Delete</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default File;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Handling State and Actions
|
|
||||||
|
|
||||||
State management is handled using React hooks like `useState` to manage the file system data. Actions such as adding, renaming, and deleting items update the state accordingly.
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
const [files, setFiles] = useState(initialData);
|
|
||||||
|
|
||||||
const addItem = (parentId, type) => {
|
|
||||||
// Logic to add a new item to the file system
|
|
||||||
};
|
|
||||||
|
|
||||||
const renameItem = (id, newName) => {
|
|
||||||
// Logic to rename an existing item
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteItem = (id) => {
|
|
||||||
// Logic to delete an item
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conclusion: Building a Dynamic File System with React
|
|
||||||
|
|
||||||
Creating a recursive file system in React is a powerful way to manage hierarchical data and provide a dynamic user experience. By leveraging React's component-based architecture and state management, you can build interactive file systems that handle complex nested structures efficiently.
|
|
||||||
|
|
||||||
Check out the full implementation on [GitHub](https://github.com/nisabmohd/recursive-file-system-react) and explore how these concepts can be applied to your own projects. Happy coding!
|
|
||||||
|
|
||||||
🚀📁
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Building a Dynamic Documentation Platform with DocuBook: A Deep Dive"
|
|
||||||
description: "Explore how DocuBook simplifies the creation of interactive and dynamic documentation platforms. This blog post provides insights into its features, changelog updates, and how it enhances the documentation experience."
|
|
||||||
date: 31-12-2024
|
|
||||||
authors:
|
|
||||||
- avatar: "https://ui.shadcn.com/avatars/02.png"
|
|
||||||
handle: mywildancloud
|
|
||||||
username: Wildan nrs
|
|
||||||
handleUrl: "https://github.com/mywildancloud"
|
|
||||||
cover: "https://img.freepik.com/free-photo/high-angle-designer-working-floor_23-2149930985.jpg?t=st=1735654027~exp=1735657627~hmac=2cd2d48f845d1691a28992aec65a3bc11b8e28680d06eeff218e8a773fffc36e&w=2000"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Introduction: Why DocuBook Stands Out in Documentation Platforms
|
|
||||||
|
|
||||||
In the fast-evolving world of web development, creating and maintaining well-structured documentation is crucial. Whether you're working on open-source projects, internal tools, or client deliverables, a robust documentation platform can make all the difference. Enter **DocuBook**—a modern, interactive, and dynamic platform designed to streamline the documentation process.
|
|
||||||
|
|
||||||
In this blog post, we’ll explore the key features of DocuBook, highlight recent updates from its [changelog](https://www.docubook.pro/changelog), and discuss why it’s an essential tool for developers and teams.
|
|
||||||
|
|
||||||
## Key Features of DocuBook
|
|
||||||
|
|
||||||
### 1. Interactive Documentation
|
|
||||||
|
|
||||||
DocuBook offers a seamless user experience with interactive elements such as expandable sections, live code previews, and integrated search functionality. This ensures that users can quickly find and engage with the content they need.
|
|
||||||
|
|
||||||
### 2. Dynamic Changelog Management
|
|
||||||
|
|
||||||
The changelog feature in DocuBook allows teams to document updates, bug fixes, and feature rollouts in a structured format. This helps maintain transparency and keeps users informed about the latest developments.
|
|
||||||
|
|
||||||
### 3. Customizable Themes
|
|
||||||
|
|
||||||
DocuBook supports theme customization, enabling you to align the documentation's appearance with your brand identity. From color schemes to typography, you have full control over the design.
|
|
||||||
|
|
||||||
### 4. Built-in Versioning
|
|
||||||
|
|
||||||
With built-in versioning, DocuBook makes it easy to manage multiple versions of your documentation. This is particularly useful for projects that evolve over time or have distinct releases.
|
|
||||||
|
|
||||||
### 5. Markdown and MDX Support
|
|
||||||
|
|
||||||
Leverage the simplicity of Markdown and the flexibility of MDX to create rich, component-based documentation. DocuBook seamlessly integrates with modern frameworks like React for a smooth development experience.
|
|
||||||
|
|
||||||
## Recent Updates: Highlights from the Changelog
|
|
||||||
|
|
||||||
## [v1.3.0] - 2024-12-31
|
|
||||||
|
|
||||||
> Release Note Feature to Make it Easier to Write Changelogs
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- New Release Note Feature
|
|
||||||
- New Layout for Changelog page
|
|
||||||
- New Changelog page
|
|
||||||
- Add Release Note Component
|
|
||||||
- Easily write release notes directly from the CHANGELOG.md file
|
|
||||||
- TOC for versioning
|
|
||||||
- Write with the markdown tag
|
|
||||||
- Add lib / changelog.ts
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
|
|
||||||
- Improvement Responsive feature image for Version Entry
|
|
||||||
- Improvement Layout for changelog page
|
|
||||||
- Improvement Padding on mobile devices
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Fix og:image not showing on Page.tsx
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Remove excessive padding
|
|
||||||
- Only use containers of md size
|
|
||||||
- Remove Logo on Footer
|
|
||||||
|
|
||||||
For a full list of updates, visit the [changelog](https://www.docubook.pro/changelog).
|
|
||||||
|
|
||||||
## Why Choose DocuBook?
|
|
||||||
|
|
||||||
### Streamlined Collaboration
|
|
||||||
|
|
||||||
DocuBook enables teams to collaborate effectively by providing tools for inline comments, suggestions, and version control. This ensures that everyone stays on the same page.
|
|
||||||
|
|
||||||
### Developer-Friendly
|
|
||||||
|
|
||||||
With support for modern frameworks, DocuBook simplifies the integration process. Whether you’re using React, Next.js, or plain HTML, DocuBook adapts to your workflow.
|
|
||||||
|
|
||||||
### Scalability
|
|
||||||
|
|
||||||
Designed to handle projects of any size, DocuBook scales effortlessly, making it ideal for both small teams and enterprise-level organizations.
|
|
||||||
|
|
||||||
## Conclusion: Elevate Your Documentation Game with DocuBook
|
|
||||||
|
|
||||||
DocuBook is more than just a documentation tool; it’s a platform that empowers developers and teams to create, manage, and maintain high-quality documentation with ease. Its rich feature set, combined with regular updates, makes it a must-have for any project.
|
|
||||||
|
|
||||||
Ready to transform your documentation experience? Explore more at [DocuBook](https://www.docubook.pro) and start building documentation that stands out.
|
|
||||||
|
|
||||||
🚀📚
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Using React Server Components and Server Actions in Next.js"
|
|
||||||
description: "Explore how to leverage React Server Components and Server Actions in Next.js to build modern, efficient web applications. Learn how these features enhance performance and simplify server-side logic."
|
|
||||||
date: 05-09-2024
|
|
||||||
authors:
|
|
||||||
- avatar: "https://ui.shadcn.com/avatars/02.png"
|
|
||||||
handle: reactdev
|
|
||||||
username: React Dev
|
|
||||||
handleUrl: "https://github.com/reactdev"
|
|
||||||
- avatar: "https://ui.shadcn.com/avatars/01.png"
|
|
||||||
handle: nextjsguru
|
|
||||||
username: Next.js Guru
|
|
||||||
handleUrl: "https://github.com/nextjsguru"
|
|
||||||
cover: "https://img.freepik.com/premium-vector/many-monsters-various-colors-doodle-come-bless-birthday-happy_577083-85.jpg?w=826"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Introduction: Enhancing Next.js with React Server Components
|
|
||||||
|
|
||||||
Next.js has evolved to include powerful features like React Server Components and Server Actions, which offer a new way to handle server-side rendering and logic. These features provide a more efficient and streamlined approach to building web applications, allowing you to fetch data and render components on the server without compromising performance.
|
|
||||||
|
|
||||||
In this blog post, we'll explore how to use React Server Components and Server Actions in Next.js with practical examples and code snippets.
|
|
||||||
|
|
||||||
## What Are React Server Components?
|
|
||||||
|
|
||||||
React Server Components (RSC) are a new type of component introduced by React that allows you to render components on the server. This approach helps reduce the amount of JavaScript sent to the client and enhances performance by offloading rendering work to the server.
|
|
||||||
|
|
||||||
### Benefits of React Server Components
|
|
||||||
|
|
||||||
- **Improved Performance**: By rendering on the server, you reduce the amount of client-side JavaScript and improve load times.
|
|
||||||
- **Enhanced User Experience**: Faster initial page loads and smoother interactions.
|
|
||||||
- **Simplified Data Fetching**: Fetch data on the server and pass it directly to components.
|
|
||||||
|
|
||||||
### Example: Creating a Server Component
|
|
||||||
|
|
||||||
Here’s a basic example of a React Server Component in a Next.js application:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// app/components/UserProfile.server.js
|
|
||||||
import { getUserData } from "../lib/api";
|
|
||||||
|
|
||||||
export default async function UserProfile() {
|
|
||||||
const user = await getUserData();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>{user.name}</h1>
|
|
||||||
<p>{user.email}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
In this example, `UserProfile` is a server component that fetches user data on the server and renders it.
|
|
||||||
|
|
||||||
## What Are Server Actions?
|
|
||||||
|
|
||||||
Server Actions are functions that run on the server in response to user interactions or other events. They allow you to handle server-side logic, such as form submissions or API requests, directly from your React components.
|
|
||||||
|
|
||||||
### Benefits of Server Actions
|
|
||||||
|
|
||||||
- **Simplified Server Logic**: Write server-side code directly in your components.
|
|
||||||
- **Enhanced Security**: Handle sensitive operations on the server rather than the client.
|
|
||||||
- **Improved Performance**: Reduce client-side JavaScript and offload tasks to the server.
|
|
||||||
|
|
||||||
### Example: Using Server Actions
|
|
||||||
|
|
||||||
Here’s how you can use Server Actions in a Next.js application to handle form submissions:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// app/actions/submitForm.js
|
|
||||||
import { saveFormData } from "../lib/api";
|
|
||||||
|
|
||||||
export async function submitForm(data) {
|
|
||||||
await saveFormData(data);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// app/components/ContactForm.js
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { submitForm } from "../actions/submitForm";
|
|
||||||
|
|
||||||
export default function ContactForm() {
|
|
||||||
const handleSubmit = async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const formData = new FormData(event.target);
|
|
||||||
const result = await submitForm(Object.fromEntries(formData));
|
|
||||||
if (result.success) {
|
|
||||||
alert("Form submitted successfully!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<label>
|
|
||||||
Name:
|
|
||||||
<input type="text" name="name" required />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Email:
|
|
||||||
<input type="email" name="email" required />
|
|
||||||
</label>
|
|
||||||
<button type="submit">Submit</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
In this example, `submitForm` is a server action that processes form data on the server, and `ContactForm` is a client component that uses this action to handle form submissions.
|
|
||||||
|
|
||||||
## Conclusion: Leveraging Modern Features for Better Web Apps
|
|
||||||
|
|
||||||
React Server Components and Server Actions in Next.js provide powerful tools for building efficient, modern web applications. By leveraging these features, you can improve performance, simplify server-side logic, and create a more responsive user experience.
|
|
||||||
|
|
||||||
As you build your Next.js applications, consider incorporating React Server Components and Server Actions to take full advantage of the latest advancements in web development.
|
|
||||||
|
|
||||||
Happy coding!
|
|
||||||
|
|
||||||
🚀✨
|
|
||||||
2
hooks/index.ts
Normal file
2
hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './useScrollPosition';
|
||||||
|
export * from './useActiveSection';
|
||||||
68
hooks/useActiveSection.ts
Normal file
68
hooks/useActiveSection.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { TocItem } from '@/lib/toc';
|
||||||
|
|
||||||
|
export function useActiveSection(tocs: TocItem[]) {
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||||
|
const clickedIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Handle intersection observer for active section
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === 'undefined' || !tocs.length) return;
|
||||||
|
|
||||||
|
const handleIntersect = (entries: IntersectionObserverEntry[]) => {
|
||||||
|
if (clickedIdRef.current) return;
|
||||||
|
|
||||||
|
const visibleEntries = entries.filter(entry => entry.isIntersecting);
|
||||||
|
if (!visibleEntries.length) return;
|
||||||
|
|
||||||
|
// Find the most visible entry
|
||||||
|
const mostVisibleEntry = visibleEntries.reduce((prev, current) => {
|
||||||
|
return current.intersectionRatio > prev.intersectionRatio ? current : prev;
|
||||||
|
}, visibleEntries[0]);
|
||||||
|
|
||||||
|
const newActiveId = mostVisibleEntry.target.id;
|
||||||
|
if (newActiveId !== activeId) {
|
||||||
|
setActiveId(newActiveId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize intersection observer
|
||||||
|
observerRef.current = new IntersectionObserver(handleIntersect, {
|
||||||
|
root: null,
|
||||||
|
rootMargin: '0px 0px -80% 0px',
|
||||||
|
threshold: 0.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Observe all headings
|
||||||
|
tocs.forEach(toc => {
|
||||||
|
const element = document.getElementById(toc.href.slice(1));
|
||||||
|
if (element) {
|
||||||
|
observerRef.current?.observe(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
observerRef.current?.disconnect();
|
||||||
|
};
|
||||||
|
}, [tocs, activeId]);
|
||||||
|
|
||||||
|
const handleLinkClick = useCallback((id: string) => {
|
||||||
|
clickedIdRef.current = id;
|
||||||
|
setActiveId(id);
|
||||||
|
|
||||||
|
// Reset clicked state after scroll completes
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
clickedIdRef.current = null;
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeId,
|
||||||
|
setActiveId,
|
||||||
|
handleLinkClick,
|
||||||
|
};
|
||||||
|
}
|
||||||
28
hooks/useScrollPosition.ts
Normal file
28
hooks/useScrollPosition.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useScrollPosition(threshold = 0.5) {
|
||||||
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const scrollPosition = window.scrollY;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const shouldBeSticky = scrollPosition > viewportHeight * threshold;
|
||||||
|
|
||||||
|
setIsScrolled(prev => shouldBeSticky !== prev ? shouldBeSticky : prev);
|
||||||
|
}, [threshold]);
|
||||||
|
|
||||||
|
// Add scroll event listener
|
||||||
|
useEffect(() => {
|
||||||
|
// Initial check
|
||||||
|
handleScroll();
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}, [handleScroll]);
|
||||||
|
|
||||||
|
return isScrolled;
|
||||||
|
}
|
||||||
@@ -12,18 +12,20 @@ import matter from "gray-matter";
|
|||||||
|
|
||||||
// custom components imports
|
// custom components imports
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import Pre from "@/components/markdown/pre";
|
import Pre from "@/components/markdown/PreMdx";
|
||||||
import Note from "@/components/markdown/note";
|
import Note from "@/components/markdown/NoteMdx";
|
||||||
import { Stepper, StepperItem } from "@/components/markdown/stepper";
|
import { Stepper, StepperItem } from "@/components/markdown/StepperMdx";
|
||||||
import Image from "@/components/markdown/image";
|
import Image from "@/components/markdown/ImageMdx";
|
||||||
import Link from "@/components/markdown/link";
|
import Link from "@/components/markdown/LinkMdx";
|
||||||
import Outlet from "@/components/markdown/outlet";
|
import Outlet from "@/components/markdown/OutletMdx";
|
||||||
import Youtube from "@/components/markdown/youtube";
|
import Youtube from "@/components/markdown/YoutubeMdx";
|
||||||
import Tooltip from "@/components/markdown/tooltips";
|
import Tooltip from "@/components/markdown/TooltipsMdx";
|
||||||
import Card from "@/components/markdown/card";
|
import Card from "@/components/markdown/CardMdx";
|
||||||
import Button from "@/components/markdown/button";
|
import Button from "@/components/markdown/ButtonMdx";
|
||||||
import Accordion from "@/components/markdown/accordion";
|
import Accordion from "@/components/markdown/AccordionMdx";
|
||||||
import CardGroup from "@/components/markdown/cardgroup";
|
import CardGroup from "@/components/markdown/CardGroupMdx";
|
||||||
|
import Kbd from "@/components/markdown/KeyboardMdx";
|
||||||
|
import { Release, Changes } from "@/components/markdown/ReleaseMdx";
|
||||||
|
|
||||||
// add custom components
|
// add custom components
|
||||||
const components = {
|
const components = {
|
||||||
@@ -44,6 +46,9 @@ const components = {
|
|||||||
Button,
|
Button,
|
||||||
Accordion,
|
Accordion,
|
||||||
CardGroup,
|
CardGroup,
|
||||||
|
Kbd,
|
||||||
|
Release,
|
||||||
|
Changes,
|
||||||
};
|
};
|
||||||
|
|
||||||
// can be used for other pages like blogs, Guides etc
|
// can be used for other pages like blogs, Guides etc
|
||||||
@@ -177,13 +182,15 @@ const postProcess = () => (tree: any) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Author = {
|
// export type Author = {
|
||||||
avatar?: string;
|
// avatar?: string;
|
||||||
handle: string;
|
// handle: string;
|
||||||
username: string;
|
// username: string;
|
||||||
handleUrl: string;
|
// handleUrl: string;
|
||||||
};
|
// };
|
||||||
|
|
||||||
|
// Blog related types and functions have been removed
|
||||||
|
/*
|
||||||
export type BlogMdxFrontmatter = BaseMdxFrontmatter & {
|
export type BlogMdxFrontmatter = BaseMdxFrontmatter & {
|
||||||
date: string;
|
date: string;
|
||||||
authors: Author[];
|
authors: Author[];
|
||||||
@@ -197,20 +204,26 @@ export async function getAllBlogStaticPaths() {
|
|||||||
return res.map((file) => file.split(".")[0]);
|
return res.map((file) => file.split(".")[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllBlogs() {
|
export async function getAllBlogs() {
|
||||||
const blogFolder = path.join(process.cwd(), "/contents/blogs/");
|
const blogFolder = path.join(process.cwd(), "/contents/blogs/");
|
||||||
const files = await fs.readdir(blogFolder);
|
const files = await fs.readdir(blogFolder);
|
||||||
const uncheckedRes = await Promise.all(
|
const uncheckedRes = await Promise.all(
|
||||||
files.map(async (file) => {
|
files.map(async (file) => {
|
||||||
if (!file.endsWith(".mdx")) return undefined;
|
try {
|
||||||
const filepath = path.join(process.cwd(), `/contents/blogs/${file}`);
|
const filepath = path.join(process.cwd(), `/contents/blogs/${file}`);
|
||||||
const rawMdx = await fs.readFile(filepath, "utf-8");
|
const rawMdx = await fs.readFile(filepath, "utf-8");
|
||||||
return {
|
return {
|
||||||
...justGetFrontmatterFromMD<BlogMdxFrontmatter>(rawMdx),
|
...justGetFrontmatterFromMD<BlogMdxFrontmatter>(rawMdx),
|
||||||
slug: file.split(".")[0],
|
slug: file.split(".")[0],
|
||||||
};
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return uncheckedRes.filter((it) => !!it) as (BlogMdxFrontmatter & {
|
return uncheckedRes.filter((it) => !!it) as (BlogMdxFrontmatter & {
|
||||||
@@ -219,11 +232,13 @@ export async function getAllBlogs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getBlogForSlug(slug: string) {
|
export async function getBlogForSlug(slug: string) {
|
||||||
const blogFile = path.join(process.cwd(), "/contents/blogs/", `${slug}.mdx`);
|
|
||||||
try {
|
try {
|
||||||
|
const blogFile = path.join(process.cwd(), "/contents/blogs/", `${slug}.mdx`);
|
||||||
const rawMdx = await fs.readFile(blogFile, "utf-8");
|
const rawMdx = await fs.readFile(blogFile, "utf-8");
|
||||||
return await parseMdx<BlogMdxFrontmatter>(rawMdx);
|
return await parseMdx<BlogMdxFrontmatter>(rawMdx);
|
||||||
} catch {
|
} catch (err) {
|
||||||
return undefined;
|
console.log(err);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|||||||
9
lib/toc.ts
Normal file
9
lib/toc.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface TocItem {
|
||||||
|
level: number;
|
||||||
|
text: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobTocProps {
|
||||||
|
tocs: TocItem[];
|
||||||
|
}
|
||||||
2605
package-lock.json
generated
2605
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "docubook",
|
"name": "docubook",
|
||||||
"version": "1.8.5",
|
"version": "1.11.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"framer-motion": "^12.4.1",
|
"framer-motion": "^12.4.1",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"lucide-react": "^0.435.0",
|
"lucide-react": "^0.511.0",
|
||||||
"next": "^14.2.6",
|
"next": "^14.2.6",
|
||||||
"next-mdx-remote": "^5.0.0",
|
"next-mdx-remote": "^5.0.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
|||||||
@@ -98,17 +98,3 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px; /* Sudut melengkung pada iframe */
|
border-radius: 8px; /* Sudut melengkung pada iframe */
|
||||||
}
|
}
|
||||||
|
|
||||||
.youtube:hover {
|
|
||||||
transform: scale(1.05); /* Efek hover zoom */
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
li#changelog {
|
|
||||||
padding-left: 1.5em;
|
|
||||||
text-indent: -1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[id^="version-"] {
|
|
||||||
scroll-margin-top: 4rem; /* sesuaikan dengan tinggi navbar */
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user