initial docs
This commit is contained in:
92
app/blog/[slug]/page.tsx
Normal file
92
app/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Typography } from "@/components/docs/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/docs/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
9
app/blog/layout.tsx
Normal 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
98
app/blog/page.tsx
Normal 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] pt-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
11
app/changelog/layout.tsx
Normal 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
63
app/changelog/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
app/docs/[[...slug]]/page.tsx
Normal file
105
app/docs/[[...slug]]/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getDocsForSlug, getDocsTocs } from "@/lib/markdown";
|
||||
import DocsBreadcrumb from "@/components/docs/docs-breadcrumb";
|
||||
import Pagination from "@/components/docs/pagination";
|
||||
import Toc from "@/components/docs/toc";
|
||||
import { Typography } from "@/components/docs/typography";
|
||||
import EditThisPage from "@/components/docs/edit-on-github";
|
||||
import { formatDate2 } from "@/lib/utils";
|
||||
import docuConfig from "@/docu.json";
|
||||
import MobToc from "@/components/docs/mob-toc";
|
||||
import { ScrollToTop } from "@/components/docs/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
14
app/docs/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Leftbar } from "@/components/docs/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
44
app/error.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
18
app/hire-me/page.tsx
Normal file
18
app/hire-me/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { getMetadata } from "@/app/layout";
|
||||
|
||||
export const metadata = getMetadata({
|
||||
title: "Hire Me",
|
||||
description: "Hire me to start a documentation project with DocuBook",
|
||||
});
|
||||
|
||||
export default function EmbeddedHTML() {
|
||||
return (
|
||||
<div className="w-full py-0 dark:bg-transparent mx-auto min-h-svh">
|
||||
<iframe
|
||||
src="/hire-me.html"
|
||||
width="100%"
|
||||
height="1000"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
app/layout.tsx
Normal file
96
app/layout.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ThemeProvider } from "@/components/contexts/theme-provider";
|
||||
import { Navbar } from "@/components/docs/navbar";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { GeistMono } from "geist/font/mono";
|
||||
import { Footer } from "@/components/docs/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
19
app/not-found.tsx
Normal 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're looking for doesn't exist.</p>
|
||||
</div>
|
||||
<Link href="/" className={buttonVariants({})}>
|
||||
Back to homepage
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
app/page.tsx
Normal file
93
app/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
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 min-h:[100vh] items-center justify-center text-center px-4 sm:py-40 py-12">
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold mb-6 sm:text-6xl">DocuBook Starter Templates</h1>
|
||||
<p className="mb-10 sm:text-xl max-w-[800px] 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 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
16
app/playground/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
510
app/playground/page.tsx
Normal file
510
app/playground/page.tsx
Normal file
@@ -0,0 +1,510 @@
|
||||
"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
|
||||
} from "lucide-react";
|
||||
import { Button as UIButton } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
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 newText = before + text + after;
|
||||
setMarkdown(newText);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textArea.focus();
|
||||
const newPosition = start + text.length;
|
||||
textArea.setSelectionRange(newPosition, newPosition);
|
||||
});
|
||||
};
|
||||
|
||||
const handleParagraphClick = () => {
|
||||
const textArea = document.querySelector('textarea');
|
||||
if (textArea) {
|
||||
insertAtCursor(textArea, 'this is regular text, **bold text**, *italic text*\n');
|
||||
}
|
||||
};
|
||||
|
||||
const handleHeading2Click = () => {
|
||||
const textArea = document.querySelector('textarea');
|
||||
if (textArea) {
|
||||
insertAtCursor(textArea, '## Heading 2\n');
|
||||
}
|
||||
};
|
||||
|
||||
const handleHeading3Click = () => {
|
||||
const textArea = document.querySelector('textarea');
|
||||
if (textArea) {
|
||||
insertAtCursor(textArea, '### Heading 3\n');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulletListClick = () => {
|
||||
const textArea = document.querySelector('textarea');
|
||||
if (textArea) {
|
||||
insertAtCursor(textArea, '- List One\n- List Two\n- Other List\n');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNumberedListClick = () => {
|
||||
const textArea = document.querySelector('textarea');
|
||||
if (textArea) {
|
||||
insertAtCursor(textArea, '1. Number One\n2. Number Two\n3. Number Three\n');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinkClick = () => {
|
||||
const textArea = document.querySelector('textarea');
|
||||
if (textArea) {
|
||||
insertAtCursor(textArea, '[Visit OpenAI](https://www.openai.com)\n');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageClick = () => {
|
||||
const textArea = document.querySelector('textarea');
|
||||
if (textArea) {
|
||||
insertAtCursor(textArea, '\n');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlockquoteClick = () => {
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCodeBlockClick = () => {
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableClick = () => {
|
||||
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. |
|
||||
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNoteClick = (type: string) => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComponentClick = (component: string) => {
|
||||
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]);
|
||||
};
|
||||
|
||||
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={Type} label="Paragraph" onClick={handleParagraphClick} />
|
||||
<ToolbarButton icon={Heading2} label="Heading 2" onClick={handleHeading2Click} />
|
||||
<ToolbarButton icon={Heading3} label="Heading 3" onClick={handleHeading3Click} />
|
||||
<ToolbarButton icon={List} label="Bullet List" onClick={handleBulletListClick} />
|
||||
<ToolbarButton icon={ListOrdered} label="Numbered List" onClick={handleNumberedListClick} />
|
||||
<ToolbarSeparator />
|
||||
<ToolbarButton icon={Code} label="Code Block" onClick={handleCodeBlockClick} />
|
||||
<ToolbarButton icon={Quote} label="Blockquote" onClick={handleBlockquoteClick} />
|
||||
<ToolbarButton icon={ImageIcon} label="Image" onClick={handleImageClick} />
|
||||
<ToolbarButton icon={LinkIcon} label="Link" onClick={handleLinkClick} />
|
||||
<ToolbarButton icon={Table} label="Table" onClick={handleTableClick} />
|
||||
<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 text-muted-foreground" />
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</UIButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => handleNoteClick('note')}>
|
||||
Note
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleNoteClick('danger')}>
|
||||
Danger
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleNoteClick('warning')}>
|
||||
Warning
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleNoteClick('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 text-muted-foreground" />
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</UIButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => handleComponentClick('stepper')}>
|
||||
<Rows className="h-4 w-4 mr-2" />
|
||||
Stepper
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleComponentClick('card')}>
|
||||
<LayoutGrid className="h-4 w-4 mr-2" />
|
||||
Card
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleComponentClick('button')}>
|
||||
<MousePointer2 className="h-4 w-4 mr-2" />
|
||||
Button
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleComponentClick('accordion')}>
|
||||
<ChevronDown className="h-4 w-4 mr-2" />
|
||||
Accordion
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleComponentClick('tabs')}>
|
||||
<LayoutPanelTop className="h-4 w-4 mr-2" />
|
||||
Tabs
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleComponentClick('youtube')}>
|
||||
<YoutubeIcon className="h-4 w-4 mr-2" />
|
||||
Youtube
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleComponentClick('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="Start writing markdown..."
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user