initial commit.

This commit is contained in:
2025-05-03 21:10:51 +07:00
parent 3f9d8a1fa0
commit 474bc266c7
133 changed files with 24259 additions and 0 deletions

BIN
app/.DS_Store vendored Normal file

Binary file not shown.

BIN
app/blog/.DS_Store vendored Normal file

Binary file not shown.

92
app/blog/[slug]/page.tsx Normal file
View File

@@ -0,0 +1,92 @@
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>
);
}

9
app/blog/layout.tsx Normal file
View File

@@ -0,0 +1,9 @@
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>
);
}

98
app/blog/page.tsx Normal file
View File

@@ -0,0 +1,98 @@
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 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="w-full mx-auto flex flex-col gap-1 sm:min-h-[91vh] min-h-[88vh] py-2">
<div className="mb-7 flex flex-col gap-2">
<h1 className="text-2xl font-extrabold">
Blog Posts
</h1>
<p className="text-lg text-muted-foreground mt-2">
Discover the latest updates, tutorials, and insights on {meta.title}.
</p>
</div>
<div className="grid md:grid-cols-3 sm:grid-cols-2 grid-cols-1 sm:gap-8 gap-4 mb-5">
{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 py-5 px-3 min-h-[400px]"
>
<h3 className="text-md font-semibold -mt-1 pr-7">{title}</h3>
<div className="w-full">
<Image
src={cover}
alt={title}
width={400}
height={150}
quality={80}
className="w-full rounded-md object-cover h-[180px] border"
/>
</div>
<p className="text-sm text-muted-foreground">{description}</p>
<div className="flex items-center justify-between w-full mt-auto">
<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>
);
}

11
app/changelog/layout.tsx Normal file
View File

@@ -0,0 +1,11 @@
export default function ChangelogLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex items-start gap-8">
{children}
</div>
);
}

63
app/changelog/page.tsx Normal file
View File

@@ -0,0 +1,63 @@
import { Suspense } from "react";
import { getChangelogEntries } from "@/lib/changelog";
import { VersionEntry } from "@/components/changelog/version-entry";
import { VersionToc } from "@/components/changelog/version-toc";
import { getMetadata } from "@/app/layout";
import docuConfig from "@/docu.json";
import { FloatingVersionToc } from "@/components/changelog/floating-version";
export const metadata = getMetadata({
title: "Changelog",
description: "Latest updates and improvements to DocuBook",
image: "release-note.png",
});
export default async function ChangelogPage() {
const entries = await getChangelogEntries();
const { meta } = docuConfig;
return (
<div className="flex flex-col w-full">
<div className="border-b">
<div className="py-8">
<h1 className="text-2xl font-extrabold">Changelog</h1>
<p className="text-lg text-muted-foreground mt-2">
Latest updates and improvements to {meta.title}
</p>
</div>
</div>
<div className="md:container py-8">
<div className="flex items-start gap-8">
<Suspense fallback={<div className="lg:flex hidden flex-[1.5] min-w-[238px]" />}>
<VersionToc
versions={entries.map(({ version, date }) => ({ version, date }))}
/>
</Suspense>
<main className="flex-1 lg:flex-[5.25] min-w-0">
<div className="relative">
<div className="absolute left-0 top-0 h-full w-px bg-border lg:block hidden" />
<div className="lg:pl-12 pl-0 lg:pt-8">
{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>
</div>
</div>
{/* Floating TOC for smaller screens */}
{entries.length > 0 && (
<FloatingVersionToc
versions={entries.map(({ version, date }) => ({ version, date }))}
/>
)}
</div>
);
}

BIN
app/docs/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,105 @@
import { notFound } from "next/navigation";
import { getDocsForSlug, getDocsTocs } from "@/lib/markdown";
import DocsBreadcrumb from "@/components/docs-breadcrumb";
import Pagination from "@/components/pagination";
import Toc from "@/components/toc";
import { Typography } from "@/components/typography";
import EditThisPage from "@/components/edit-on-github";
import { formatDate2 } from "@/lib/utils";
import docuConfig from "@/docu.json";
import MobToc from "@/components/mob-toc";
import { ScrollToTop } from "@/components/scroll-to-top";
const { meta } = docuConfig;
type PageProps = {
params: {
slug: string[];
};
};
// Function to generate metadata dynamically
export async function generateMetadata({ params: { slug = [] } }: PageProps) {
const pathName = slug.join("/");
const res = await getDocsForSlug(pathName);
if (!res) {
return {
title: "Page Not Found",
description: "The requested page was not found.",
};
}
const { title, description, image } = res.frontmatter;
// Absolute URL for og:image
const ogImage = image
? `${meta.baseURL}/images/${image}`
: `${meta.baseURL}/images/og-image.png`;
return {
title: `${title}`,
description,
openGraph: {
title,
description,
url: `${meta.baseURL}/docs/${pathName}`,
type: "article",
images: [
{
url: ogImage,
width: 1200,
height: 630,
alt: title,
},
],
},
twitter: {
card: "summary_large_image",
title,
description,
images: [ogImage],
},
};
}
export default async function DocsPage({ params: { slug = [] } }: PageProps) {
const pathName = slug.join("/");
const res = await getDocsForSlug(pathName);
if (!res) notFound();
const { title, description, image, date } = res.frontmatter;
// File path for edit link
const filePath = `contents/docs/${slug.join("/") || ""}/index.mdx`;
const tocs = await getDocsTocs(pathName);
return (
<div className="flex items-start gap-10">
<div className="flex-[4.5] pt-10">
<DocsBreadcrumb paths={slug} />
<div className="mb-8">
<MobToc tocs={tocs} />
</div>
<Typography>
<h1 className="text-3xl !-mt-0.5">{title}</h1>
<p className="-mt-4 text-muted-foreground text-[16.5px]">{description}</p>
<div>{res.content}</div>
<div className="my-8 flex justify-between items-center border-b-2 border-x-muted-foreground">
{date && (
<p className="text-[13px] text-muted-foreground">
Published on {formatDate2(date)}
</p>
)}
<EditThisPage filePath={filePath} />
</div>
<Pagination pathname={pathName} />
</Typography>
<ScrollToTop />
</div>
<Toc path={pathName} />
</div>
);
}

14
app/docs/layout.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { Leftbar } from "@/components/leftbar";
export default function DocsLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className="flex items-start gap-8">
<Leftbar key="leftbar" />
<div className="flex-[5.25]">{children}</div>
</div>
);
}

44
app/error.tsx Normal file
View File

@@ -0,0 +1,44 @@
"use client"; // Error components must be Client Components
import { Button, buttonVariants } from "@/components/ui/button";
import Link from "next/link";
import { useEffect } from "react";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="min-h-[87vh] px-2 sm:py-28 py-36 flex flex-col gap-4 items-center">
<div className="text-center flex flex-col items-center justify-center w-fit gap-2">
<h2 className="text-7xl font-bold pr-1">Oops!</h2>
<p className="text-muted-foreground text-md font-medium">
Something went wrong {":`("}
</p>
<p>
We&apos;re sorry, but an error occurred while processing your request.
</p>
</div>
<div className="flex items-center gap-2">
<Button
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
>
Reload page
</Button>
<Link href="/" className={buttonVariants({})}>
Back to homepage
</Link>
</div>
</div>
);
}

96
app/layout.tsx Normal file
View File

@@ -0,0 +1,96 @@
import type { Metadata } from "next";
import { ThemeProvider } from "@/components/contexts/theme-provider";
import { Navbar } from "@/components/navbar";
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
import { Footer } from "@/components/footer";
import docuConfig from "@/docu.json";
import { Toaster } from "@/components/ui/sonner";
import "@/styles/globals.css";
const { meta } = docuConfig;
// Default Metadata
const defaultMetadata: Metadata = {
metadataBase: new URL(meta.baseURL),
description: meta.description,
title: meta.title,
icons: {
icon: meta.favicon,
},
openGraph: {
title: meta.title,
description: meta.description,
images: [
{
url: new URL("/images/og-image.png", meta.baseURL).toString(),
width: 1200,
height: 630,
alt: String(meta.title),
},
],
locale: "en_US",
type: "website",
},
};
// Dynamic Metadata Getter
export function getMetadata({
title,
description,
image,
}: {
title?: string;
description?: string;
image?: string;
}): Metadata {
const ogImage = image ? new URL(`/images/${image}`, meta.baseURL).toString() : undefined;
return {
...defaultMetadata,
title: title ? `${title}` : defaultMetadata.title,
description: description || defaultMetadata.description,
openGraph: {
...defaultMetadata.openGraph,
title: title || defaultMetadata.openGraph?.title,
description: description || defaultMetadata.openGraph?.description,
images: ogImage ? [
{
url: ogImage,
width: 1200,
height: 630,
alt: String(title || defaultMetadata.openGraph?.title),
},
] : defaultMetadata.openGraph?.images,
},
};
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${GeistSans.variable} ${GeistMono.variable} font-regular antialiased`}
suppressHydrationWarning
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<Navbar />
<main className="sm:container mx-auto w-[90vw] h-auto scroll-smooth">
{children}
</main>
<Footer />
<Toaster position="top-center" />
</ThemeProvider>
</body>
</html>
);
}

19
app/not-found.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { buttonVariants } from "@/components/ui/button";
import Link from "next/link";
export default function NotFound() {
return (
<div className="min-h-[87vh] px-2 sm:py-28 py-36 flex flex-col gap-4 items-center">
<div className="text-center flex flex-col items-center justify-center w-fit gap-2">
<h2 className="text-7xl font-bold pr-1">404</h2>
<p className="text-muted-foreground text-md font-medium">
Page not found {":("}
</p>
<p>Oops! The page you&apos;re looking for doesn&apos;t exist.</p>
</div>
<Link href="/" className={buttonVariants({})}>
Back to homepage
</Link>
</div>
);
}

95
app/page.tsx Normal file
View File

@@ -0,0 +1,95 @@
import { buttonVariants } from "@/components/ui/button";
import { page_routes } from "@/lib/routes-config";
import { ArrowRightIcon, FileJson, GitCommitHorizontal, SquarePlay } from "lucide-react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import AnimatedShinyText from "@/components/ui/animated-shiny-text";
import { getMetadata } from "@/app/layout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export const metadata = getMetadata({
title: "Home",
});
export default function Home() {
return (
<div className="flex flex-col items-center justify-center px-2 py-8 text-center sm:py-36">
<Link
href="/changelog"
className="mb-5 sm:text-lg flex items-center gap-2 underline underline-offset-4 sm:-mt-12"
>
<div className="z-10 flex min-h-10 items-center justify-center max-[800px]:mt-10">
<div
className={cn(
"group rounded-full border border-black/5 bg-black/5 text-base text-white transition-all ease-in hover:cursor-pointer hover:bg-accent dark:border-white/5 dark:bg-transparent dark:hover:bg-accent",
)}
>
<AnimatedShinyText className="inline-flex items-center justify-center px-4 py-1 transition ease-out hover:text-neutral-100 hover:duration-300 hover:dark:text-neutral-200">
<span>🚀 New Version - Release v.1.8.0</span>
<ArrowRightIcon className="ml-1 size-3 transition-transform duration-300 ease-in-out group-hover:translate-x-0.5" />
</AnimatedShinyText>
</div>
</div>
</Link>
<div className="w-full max-w-[800px] pb-8">
<h1 className="mb-4 text-2xl font-bold sm:text-5xl">DocuBook Starter Templates</h1>
<p className="mb-8 sm:text-xl text-muted-foreground">
Get started by editing app/page.tsx . Save and see your changes instantly.{' '}
<Link className="text-primary underline" href="https://www.docubook.pro/docs/getting-started/introduction" target="_blank">
Read Documentations
</Link>
</p>
</div>
<div className="flex flex-row items-center gap-6 mb-10">
<Link
href={`/docs${page_routes[0].href}`}
className={buttonVariants({
className: "px-6 bg-accent text-white hover:bg-primary dark:bg-accent dark:hover:bg-primary",
size: "lg",
})}
>
Get Started
</Link>
<Link
href="/playground"
className={buttonVariants({
variant: "secondary",
className: "px-6 bg-gray-200 text-gray-900 hover:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700",
size: "lg",
})}
>
Playground
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 py-12">
<Card className="px-2 py-6">
<CardHeader className="flex flex-row justify-center items-center gap-3">
<FileJson className="size-6 text-primary" />
<CardTitle className="text-xl">docu.json</CardTitle>
</CardHeader>
<CardContent>
<p>Edit the docu.json file to change the content in the header, footer and sidebar.</p>
</CardContent>
</Card>
<Card className="px-2 py-6">
<CardHeader className="flex flex-row justify-center items-center gap-3">
<GitCommitHorizontal className="size-6 text-primary" />
<CardTitle className="text-xl">CHANGELOG.md</CardTitle>
</CardHeader>
<CardContent>
<p>Manage changes to each version of your application in the CHANGELOG.md file.</p>
</CardContent>
</Card>
<Card className="px-2 py-6">
<CardHeader className="flex flex-row justify-center items-center gap-3">
<SquarePlay className="size-6 text-primary" />
<CardTitle className="text-xl">Docu Play</CardTitle>
</CardHeader>
<CardContent>
<p>Easy to write interactive markdown content with a playground.</p>
</CardContent>
</Card>
</div>
</div>
);
}

16
app/playground/layout.tsx Normal file
View File

@@ -0,0 +1,16 @@
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>
);
}

394
app/playground/page.tsx Normal file
View File

@@ -0,0 +1,394 @@
"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);
// Menambahkan satu baris kosong sebelum dan sesudah komponen
const newText = `${before}${text}\n${after}`;
setMarkdown(newText);
requestAnimationFrame(() => {
textArea.focus();
const newPosition = start + 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>
);
}