refactor: Migrate documentation content, rebuild UI components, and update core architecture.
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -32,6 +32,7 @@ out/
|
||||
.env.development
|
||||
.env.test
|
||||
.env.production
|
||||
.crawler
|
||||
|
||||
# Debug logs
|
||||
npm-debug.log*
|
||||
@@ -61,12 +62,6 @@ Thumbs.db
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Package managers
|
||||
# package-lock.json
|
||||
# yarn.lock
|
||||
# bun.lock
|
||||
# pnpm-lock.yaml
|
||||
|
||||
# Build outputs
|
||||
.next
|
||||
out
|
||||
|
||||
19
Dockerfile
19
Dockerfile
@@ -6,12 +6,16 @@ FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install Bun
|
||||
RUN npm install -g bun
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* bun.lock* ./
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
if [ -f bun.lock ]; then bun install --frozen-lockfile; \
|
||||
elif [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
@@ -27,7 +31,13 @@ COPY . .
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN npm run build
|
||||
# Install Bun for build stage if needed (depending on script)
|
||||
RUN npm install -g bun
|
||||
|
||||
RUN \
|
||||
if [ -f bun.lock ]; then bun run build; \
|
||||
else npm run build; \
|
||||
fi
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
@@ -57,3 +67,4 @@ ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
|
||||
@@ -1,46 +1,42 @@
|
||||
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 { notFound } from "next/navigation"
|
||||
import { getDocsForSlug, getDocsTocs } from "@/lib/markdown"
|
||||
import DocsBreadcrumb from "@/components/DocsBreadcrumb"
|
||||
import Pagination from "@/components/pagination"
|
||||
import Toc from "@/components/toc"
|
||||
import { Typography } from "@/components/typography"
|
||||
import EditThisPage from "@/components/EditWithGithub"
|
||||
import { formatDate2 } from "@/lib/utils"
|
||||
import docuConfig from "@/docu.json"
|
||||
import MobToc from "@/components/DocsSidebar"
|
||||
|
||||
const { meta } = docuConfig;
|
||||
const { meta } = docuConfig
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{
|
||||
slug: string[];
|
||||
}>;
|
||||
};
|
||||
slug: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
// Function to generate metadata dynamically
|
||||
export async function generateMetadata(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const params = await props.params
|
||||
|
||||
const {
|
||||
slug = []
|
||||
} = params;
|
||||
const { slug = [] } = params
|
||||
|
||||
const pathName = slug.join("/");
|
||||
const res = await getDocsForSlug(pathName);
|
||||
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;
|
||||
const { title, description, image } = res.frontmatter
|
||||
|
||||
// Absolute URL for og:image
|
||||
const ogImage = image
|
||||
? `${meta.baseURL}/images/${image}`
|
||||
: `${meta.baseURL}/images/og-image.png`;
|
||||
const ogImage = image ? `${meta.baseURL}/images/${image}` : `${meta.baseURL}/images/og-image.png`
|
||||
|
||||
return {
|
||||
title: `${title}`,
|
||||
@@ -65,53 +61,49 @@ export async function generateMetadata(props: PageProps) {
|
||||
description,
|
||||
images: [ogImage],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default async function DocsPage(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const params = await props.params
|
||||
|
||||
const {
|
||||
slug = []
|
||||
} = params;
|
||||
const { slug = [] } = params
|
||||
|
||||
const pathName = slug.join("/");
|
||||
const res = await getDocsForSlug(pathName);
|
||||
const pathName = slug.join("/")
|
||||
const res = await getDocsForSlug(pathName)
|
||||
|
||||
if (!res) notFound();
|
||||
if (!res) notFound()
|
||||
|
||||
const { title, description, image: _image, date } = res.frontmatter;
|
||||
|
||||
// File path for edit link
|
||||
const filePath = `contents/docs/${slug.join("/") || ""}/index.mdx`;
|
||||
|
||||
const tocs = await getDocsTocs(pathName);
|
||||
const { title, description, image: _image, date } = res.frontmatter
|
||||
const filePath = res.filePath
|
||||
const tocs = await getDocsTocs(pathName)
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-10">
|
||||
<div className="flex-[4.5] pt-5">
|
||||
<MobToc tocs={tocs} />
|
||||
<DocsBreadcrumb paths={slug} />
|
||||
<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 items-center border-b-2 border-dashed border-x-muted-foreground ${
|
||||
docuConfig.repository?.editLink ? "justify-between" : "justify-end"
|
||||
}`}
|
||||
<div className="flex w-full flex-1 px-0 pb-4 lg:px-8 lg:pb-8 lg:h-[calc(100vh-4rem)]">
|
||||
<div id="scroll-container" className="max-lg:scroll-p-54 bg-card dark:bg-card/20 border-muted-foreground/20 flex w-full flex-col items-start lg:h-full lg:rounded-xl rounded-b-3xl border shadow-md backdrop-blur-sm lg:flex-row lg:overflow-y-auto relative">
|
||||
<div className="flex-7 w-full min-w-0 px-4 py-4 lg:px-8 lg:py-8">
|
||||
<MobToc tocs={tocs} title={title} />
|
||||
<DocsBreadcrumb paths={slug} />
|
||||
<Typography>
|
||||
<h1 className="-mt-0.5! text-3xl">{title}</h1>
|
||||
<p className="text-muted-foreground -mt-4 text-[16.5px]">{description}</p>
|
||||
<div>{res.content}</div>
|
||||
<div
|
||||
className={`border-x-muted-foreground my-8 flex items-center border-b-2 border-dashed ${docuConfig.repository?.editLink ? "justify-between" : "justify-end"
|
||||
}`}
|
||||
>
|
||||
{docuConfig.repository?.editLink && <EditThisPage filePath={filePath} />}
|
||||
{date && (
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
Published on {formatDate2(date)}
|
||||
{docuConfig.repository?.editLink && <EditThisPage filePath={filePath} />}
|
||||
{date && (
|
||||
<p className="text-muted-foreground text-[13px]">
|
||||
Published on {formatDate2(date)}
|
||||
</p>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<Pagination pathname={pathName} />
|
||||
</Typography>
|
||||
<Pagination pathname={pathName} />
|
||||
</Typography>
|
||||
</div>
|
||||
<Toc tocs={tocs} />
|
||||
</div>
|
||||
<Toc path={pathName} />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Leftbar } from "@/components/leftbar";
|
||||
import DocsNavbar from "@/components/DocsNavbar";
|
||||
import "@/styles/override.css";
|
||||
|
||||
export default function DocsLayout({
|
||||
children,
|
||||
@@ -6,10 +8,15 @@ export default function DocsLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<div className="flex items-start gap-8">
|
||||
<Leftbar key="leftbar" />
|
||||
<div className="flex-[5.25] px-1">
|
||||
{children}
|
||||
<div className="docs-layout flex flex-col min-h-screen w-full">
|
||||
<div className="flex flex-1 items-start w-full">
|
||||
<Leftbar key="leftbar" />
|
||||
<main className="flex-1 min-w-0 dark:bg-background/50 min-h-screen flex flex-col">
|
||||
<DocsNavbar />
|
||||
<div className="flex-1 w-full">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ThemeProvider } from "@/components/contexts/theme-provider";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { GeistMono } from "geist/font/mono";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { SearchProvider } from "@/components/SearchContext";
|
||||
import docuConfig from "@/docu.json";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import "@docsearch/css";
|
||||
import "@/styles/algolia.css";
|
||||
import "@/styles/syntax.css";
|
||||
import "@/styles/override.css";
|
||||
import "@/styles/globals.css";
|
||||
|
||||
const { meta } = docuConfig;
|
||||
@@ -35,6 +35,9 @@ const defaultMetadata: Metadata = {
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
},
|
||||
other: {
|
||||
"algolia-site-verification": "6E413CE39E56BB62",
|
||||
},
|
||||
};
|
||||
|
||||
// Dynamic Metadata Getter
|
||||
@@ -86,12 +89,13 @@ export default function RootLayout({
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<Navbar />
|
||||
<main className="sm:container mx-auto w-[90vw] h-auto scroll-smooth">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
<Toaster position="top-center" />
|
||||
<SearchProvider>
|
||||
<Navbar id="main-navbar" />
|
||||
<main id="main-content" className="sm:container mx-auto w-[90vw] h-auto scroll-smooth">
|
||||
{children}
|
||||
</main>
|
||||
<Footer id="main-footer" />
|
||||
</SearchProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
16
app/page.tsx
16
app/page.tsx
@@ -1,5 +1,5 @@
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { page_routes } from "@/lib/routes-config";
|
||||
import { page_routes } from "@/lib/routes";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
LayoutDashboard,
|
||||
@@ -13,7 +13,7 @@ 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, CardDescription } from "@/components/ui/card";
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
|
||||
export const metadata = getMetadata({
|
||||
title: "WooNooW - The Ultimate WooCommerce Enhancement Suite",
|
||||
@@ -23,7 +23,7 @@ export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-8 text-center sm:py-20">
|
||||
<Link
|
||||
href="/docs/changelog"
|
||||
href="/docs/resources/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">
|
||||
@@ -33,7 +33,7 @@ export default function Home() {
|
||||
)}
|
||||
>
|
||||
<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>🚀 v2.0 Released: Multi-Channel Notifications</span>
|
||||
<span>🚀 v1.0 Released: Multi-Channel Notifications</span>
|
||||
<ArrowRightIcon className="ml-1 size-3 transition-transform duration-300 ease-in-out group-hover:translate-x-0.5" />
|
||||
</AnimatedShinyText>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@ export default function Home() {
|
||||
</Link>
|
||||
|
||||
<div className="w-full max-w-[900px] pb-8">
|
||||
<h1 className="mb-4 text-3xl font-bold sm:text-6xl bg-clip-text text-transparent bg-gradient-to-r from-green-500 to-lime-500">
|
||||
<h1 className="mb-4 text-3xl font-bold sm:text-6xl bg-clip-text text-transparent bg-linear-to-r from-green-500 to-lime-500">
|
||||
Fill the Gap. <br />Elevate Your Store.
|
||||
</h1>
|
||||
<p className="mb-8 sm:text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
@@ -52,7 +52,7 @@ export default function Home() {
|
||||
|
||||
<div className="flex flex-row items-center gap-6 mb-16">
|
||||
<Link
|
||||
href="/docs/getting-started/introduction"
|
||||
href={`/docs${page_routes[0].href}`}
|
||||
className={buttonVariants({
|
||||
className: "px-8 bg-black text-white hover:bg-neutral-800 dark:bg-white dark:text-black dark:hover:bg-neutral-200",
|
||||
size: "lg",
|
||||
@@ -74,7 +74,7 @@ export default function Home() {
|
||||
|
||||
{/* The Gap Analysis */}
|
||||
<div className="w-full max-w-5xl mb-20 text-left">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Production Reality (The Problem) */}
|
||||
<div className="p-8 rounded-2xl bg-red-50 dark:bg-red-950/20 border border-red-100 dark:border-red-900/30">
|
||||
<h3 className="text-xl font-bold mb-4 text-red-700 dark:text-red-400 flex items-center gap-2">
|
||||
@@ -123,7 +123,7 @@ export default function Home() {
|
||||
|
||||
<div className="w-full max-w-6xl">
|
||||
<h2 className="text-2xl font-bold mb-8">Core Modules</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="text-left hover:border-green-500/50 transition-colors">
|
||||
<CardHeader>
|
||||
<LayoutDashboard className="size-8 text-green-500 mb-2" />
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ROUTES, EachRoute } from "@/lib/routes-config";
|
||||
import { ROUTES, EachRoute } from "@/lib/routes";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import { ChevronsUpDown, Check, type LucideIcon } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface ContextPopoverProps {
|
||||
className?: string;
|
||||
@@ -62,7 +63,7 @@ export default function ContextPopover({ className }: ContextPopoverProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full max-w-[240px] cursor-pointer flex items-center justify-between font-semibold text-foreground px-0 pt-8",
|
||||
"w-full cursor-pointer flex items-center justify-between font-semibold text-foreground px-2 py-4 border border-muted",
|
||||
"hover:bg-transparent hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
@@ -74,7 +75,7 @@ export default function ContextPopover({ className }: ContextPopoverProps) {
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-sm">
|
||||
{activeRoute?.context?.title || activeRoute?.title || 'Select context'}
|
||||
{activeRoute?.context?.title || activeRoute?.title || <Skeleton className="h-3.5 w-24" />}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="h-4 w-4 text-foreground/50" />
|
||||
@@ -96,7 +97,7 @@ export default function ContextPopover({ className }: ContextPopoverProps) {
|
||||
key={route.href}
|
||||
onClick={() => router.push(contextPath)}
|
||||
className={cn(
|
||||
"relative flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm",
|
||||
"relative flex w-full items-center gap-2 cursor-pointer rounded px-2 py-1.5 text-sm",
|
||||
"text-left outline-none transition-colors",
|
||||
isActive
|
||||
? "bg-primary/20 text-primary dark:bg-accent/20 dark:text-accent"
|
||||
@@ -1,26 +1,27 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import React from "react";
|
||||
import { DocSearch } from "@docsearch/react";
|
||||
import { DocSearch } from "@docsearch/react"
|
||||
import { algoliaConfig } from "@/lib/search/algolia"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export default function DocSearchComponent() {
|
||||
const appId = process.env.NEXT_PUBLIC_ALGOLIA_DOCSEARCH_APP_ID;
|
||||
const apiKey = process.env.NEXT_PUBLIC_ALGOLIA_DOCSEARCH_API_KEY;
|
||||
const indexName = process.env.NEXT_PUBLIC_ALGOLIA_DOCSEARCH_INDEX_NAME;
|
||||
interface AlgoliaSearchProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function AlgoliaSearch({ className }: AlgoliaSearchProps) {
|
||||
const { appId, apiKey, indexName } = algoliaConfig
|
||||
|
||||
if (!appId || !apiKey || !indexName) {
|
||||
console.error(
|
||||
"DocSearch credentials are not set in the environment variables."
|
||||
);
|
||||
console.error("DocSearch credentials are not set in the environment variables.")
|
||||
return (
|
||||
<button className="text-sm text-muted-foreground" disabled>
|
||||
<button className="text-muted-foreground text-sm" disabled>
|
||||
Search... (misconfigured)
|
||||
</button>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="docsearch">
|
||||
<div className={cn("docsearch", className)}>
|
||||
<DocSearch
|
||||
appId={appId}
|
||||
apiKey={apiKey}
|
||||
@@ -28,5 +29,5 @@ export default function DocSearchComponent() {
|
||||
placeholder="Type something to search..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Fragment } from "react";
|
||||
|
||||
export default function DocsBreadcrumb({ paths }: { paths: string[] }) {
|
||||
return (
|
||||
<div className="pb-5 max-lg:pt-12">
|
||||
<div className="pb-5 max-lg:pt-6">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
@@ -21,10 +21,7 @@ export default function DocsBreadcrumb({ paths }: { paths: string[] }) {
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
{index < paths.length - 1 ? (
|
||||
<BreadcrumbLink
|
||||
className="a"
|
||||
href={`/docs/${paths.slice(0, index + 1).join("/")}`}
|
||||
>
|
||||
<BreadcrumbLink className="a">
|
||||
{toTitleCase(path)}
|
||||
</BreadcrumbLink>
|
||||
) : (
|
||||
63
components/DocsMenu.tsx
Normal file
63
components/DocsMenu.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { ROUTES, EachRoute } from "@/lib/routes";
|
||||
import SubLink from "./sublink";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DocsMenuProps {
|
||||
isSheet?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Get the current context from the path
|
||||
function getCurrentContext(path: string): string | undefined {
|
||||
if (!path.startsWith('/docs')) return undefined;
|
||||
|
||||
// Extract the first segment after /docs/
|
||||
const match = path.match(/^\/docs\/([^/]+)/);
|
||||
return match ? match[1] : undefined;
|
||||
}
|
||||
|
||||
// Get the route that matches the current context
|
||||
function getContextRoute(contextPath: string): EachRoute | undefined {
|
||||
return ROUTES.find(route => {
|
||||
const normalizedHref = route.href.replace(/^\/+|\/+$/g, '');
|
||||
return normalizedHref === contextPath;
|
||||
});
|
||||
}
|
||||
|
||||
export default function DocsMenu({ isSheet = false, className = "" }: DocsMenuProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Skip rendering if not on a docs page
|
||||
if (!pathname.startsWith("/docs")) return null;
|
||||
|
||||
// Get the current context
|
||||
const currentContext = getCurrentContext(pathname);
|
||||
|
||||
// Get the route for the current context
|
||||
const contextRoute = currentContext ? getContextRoute(currentContext) : undefined;
|
||||
|
||||
// If no context route is found, don't render anything
|
||||
if (!contextRoute) return null;
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Documentation navigation"
|
||||
className={cn("transition-all duration-200", className)}
|
||||
>
|
||||
<ul className="flex flex-col gap-1.5 py-4">
|
||||
{/* Display only the items from the current context */}
|
||||
<li key={contextRoute.title}>
|
||||
<SubLink
|
||||
{...contextRoute}
|
||||
href={`/docs${contextRoute.href}`}
|
||||
level={0}
|
||||
isSheet={isSheet}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
44
components/DocsNavbar.tsx
Normal file
44
components/DocsNavbar.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import Anchor from "@/components/anchor";
|
||||
import docuConfig from "@/docu.json";
|
||||
|
||||
interface NavbarItem {
|
||||
title: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const { navbar } = docuConfig;
|
||||
|
||||
export function DocsNavbar() {
|
||||
// Show all nav items
|
||||
const navItems = navbar?.menu || [];
|
||||
|
||||
return (
|
||||
<div className="hidden lg:flex items-center justify-end gap-6 h-14 px-8 mt-2">
|
||||
{/* Navigation Links */}
|
||||
<div className="flex items-center gap-6 text-sm font-medium text-foreground/80">
|
||||
{navItems.map((item: NavbarItem) => {
|
||||
const isExternal = item.href.startsWith("http");
|
||||
return (
|
||||
<Anchor
|
||||
key={`${item.title}-${item.href}`}
|
||||
href={item.href}
|
||||
absolute
|
||||
activeClassName="text-primary dark:text-accent md:font-semibold font-medium"
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
{item.title}
|
||||
{isExternal && <ArrowUpRight className="w-3.5 h-3.5" />}
|
||||
</Anchor>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocsNavbar;
|
||||
205
components/DocsSidebar.tsx
Normal file
205
components/DocsSidebar.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client"
|
||||
|
||||
import { ChevronDown, ChevronUp, PanelRight, MoreVertical } from "lucide-react"
|
||||
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTrigger } from "@/components/ui/sheet"
|
||||
import DocsMenu from "@/components/DocsMenu"
|
||||
import { ModeToggle } from "@/components/ThemeToggle"
|
||||
import { DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||
import ContextPopover from "@/components/ContextPopover"
|
||||
import TocObserver from "./TocObserver"
|
||||
import * as React from "react"
|
||||
import { useRef, useMemo } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Button } from "./ui/button"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { useActiveSection } from "@/hooks"
|
||||
import { TocItem } from "@/lib/toc"
|
||||
import Search from "@/components/SearchBox"
|
||||
import { NavMenu } from "@/components/navbar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
interface MobTocProps {
|
||||
tocs: TocItem[]
|
||||
title?: string
|
||||
}
|
||||
|
||||
const useClickOutside = (ref: React.RefObject<HTMLElement | null>, 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, title }: 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])
|
||||
|
||||
// Get title from active section if available, otherwise document title
|
||||
const activeSection = useMemo(() => {
|
||||
return tocs.find((toc) => toc.href.slice(1) === activeId)
|
||||
}, [tocs, activeId])
|
||||
|
||||
const displayTitle = activeSection?.text || title || "On this page"
|
||||
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// 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
|
||||
if (!isDocsPage || !mounted) return null
|
||||
|
||||
const chevronIcon = isExpanded ? (
|
||||
<ChevronUp className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
)
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
ref={tocRef}
|
||||
className="sticky top-0 z-50 -mx-4 -mt-4 mb-4 lg:hidden"
|
||||
initial={{ y: -100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -100, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
>
|
||||
<div className="bg-background/95 border-muted dark:border-foreground/10 dark:bg-background w-full border-b shadow-sm backdrop-blur-sm">
|
||||
<div className="p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
aria-label="Navigation menu"
|
||||
>
|
||||
<MoreVertical className="text-muted-foreground h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="flex min-w-[160px] flex-col gap-1 p-2"
|
||||
>
|
||||
<NavMenu />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-mx-1 h-auto flex-1 justify-between rounded-md px-2 py-2 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">
|
||||
<span className="text-sm font-medium capitalize">{displayTitle}</span>
|
||||
</div>
|
||||
{chevronIcon}
|
||||
</Button>
|
||||
<Search />
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="hidden max-lg:flex">
|
||||
<PanelRight className="text-muted-foreground h-6 w-6 shrink-0" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="flex w-full flex-col gap-4 px-0 lg:w-auto" side="right">
|
||||
<DialogTitle className="sr-only">Navigation Menu</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Main navigation menu with links to different sections
|
||||
</DialogDescription>
|
||||
<SheetHeader>
|
||||
<SheetClose className="px-4" asChild>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mr-8">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</SheetClose>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-4 overflow-y-auto">
|
||||
<div className="mx-2 space-y-2 px-5">
|
||||
<ContextPopover />
|
||||
<DocsMenu isSheet />
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
ref={contentRef}
|
||||
className="-mx-1 mt-2 max-h-[60vh] overflow-y-auto px-1 pb-2"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
>
|
||||
{tocs?.length ? (
|
||||
<TocObserver data={tocs} activeId={activeId} onActiveIdChange={setActiveId} />
|
||||
) : (
|
||||
<p className="text-muted-foreground py-2 text-sm">No headings</p>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
26
components/Github.tsx
Normal file
26
components/Github.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import Link from 'next/link';
|
||||
import docuConfig from "@/docu.json";
|
||||
|
||||
export default function GitHubButton() {
|
||||
const { repository } = docuConfig;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={repository.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center rounded-full p-1 text-sm font-medium text-muted-foreground border no-underline hover:bg-muted/50 transition-colors"
|
||||
aria-label="View on GitHub"
|
||||
>
|
||||
<svg
|
||||
height="16"
|
||||
width="16"
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
className="fill-current"
|
||||
>
|
||||
<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>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
'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;
|
||||
@@ -19,10 +19,14 @@ export function ScrollToTop({
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const checkScroll = useCallback(() => {
|
||||
// Check local scroll container or document
|
||||
const container = document.getElementById("scroll-container");
|
||||
const scrollY = container ? container.scrollTop : window.scrollY;
|
||||
|
||||
// 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);
|
||||
const scrolledPastHalfViewport = scrollY > (halfViewportHeight + offset);
|
||||
|
||||
// Only update state if it changes to prevent unnecessary re-renders
|
||||
if (scrolledPastHalfViewport !== isVisible) {
|
||||
@@ -42,21 +46,24 @@ export function ScrollToTop({
|
||||
timeoutId = setTimeout(checkScroll, 100);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
const container = document.getElementById("scroll-container") || window;
|
||||
container.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}, [checkScroll]);
|
||||
|
||||
const scrollToTop = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
const container = document.getElementById("scroll-container");
|
||||
if (container) {
|
||||
container.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!isVisible) return null;
|
||||
@@ -75,11 +82,11 @@ export function ScrollToTop({
|
||||
onClick={scrollToTop}
|
||||
className={cn(
|
||||
"inline-flex items-center text-sm text-muted-foreground hover:text-foreground",
|
||||
"transition-all duration-200 hover:translate-y-[-1px]"
|
||||
"transition-all duration-200 hover:translate-y-px"
|
||||
)}
|
||||
aria-label="Scroll to top"
|
||||
>
|
||||
{showIcon && <ArrowUpIcon className="mr-1 h-3.5 w-3.5 flex-shrink-0" />}
|
||||
{showIcon && <ArrowUpIcon className="mr-1 h-3.5 w-3.5 shrink-0" />}
|
||||
<span>Scroll to Top</span>
|
||||
</Link>
|
||||
</div>
|
||||
39
components/SearchBox.tsx
Normal file
39
components/SearchBox.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { Dialog } from "@/components/ui/dialog"
|
||||
import { SearchTrigger } from "@/components/SearchTrigger"
|
||||
import { SearchModal } from "@/components/SearchModal"
|
||||
import AlgoliaSearch from "@/components/DocSearch"
|
||||
import { useSearch } from "./SearchContext"
|
||||
import { DialogTrigger } from "@/components/ui/dialog"
|
||||
import { searchConfig } from "@/lib/search/config"
|
||||
|
||||
interface SearchProps {
|
||||
/**
|
||||
* Override the search type from config.
|
||||
* If not provided, uses the config value.
|
||||
*/
|
||||
type?: "default" | "algolia"
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function Search({ type, className }: SearchProps) {
|
||||
const { isOpen, setIsOpen } = useSearch()
|
||||
const searchType = type ?? searchConfig.type
|
||||
|
||||
if (searchType === "algolia") {
|
||||
return <AlgoliaSearch className={className} />
|
||||
}
|
||||
|
||||
// Logic for 'default' search
|
||||
return (
|
||||
<div className={className}>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<SearchTrigger className={className} />
|
||||
</DialogTrigger>
|
||||
<SearchModal isOpen={isOpen} setIsOpen={setIsOpen} />
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
components/SearchContext.tsx
Normal file
47
components/SearchContext.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface SearchContextType {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
const SearchContext = createContext<SearchContextType | undefined>(undefined);
|
||||
|
||||
export function SearchProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setIsOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [toggle]);
|
||||
|
||||
return (
|
||||
<SearchContext.Provider value={{ isOpen, setIsOpen, toggle }}>
|
||||
{children}
|
||||
</SearchContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSearch() {
|
||||
const context = useContext(SearchContext);
|
||||
if (!context) {
|
||||
throw new Error("useSearch must be used within a SearchProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { ArrowUpIcon, ArrowDownIcon, CornerDownLeftIcon, FileTextIcon } from "lucide-react";
|
||||
import Anchor from "./anchor";
|
||||
import { advanceSearch, cn } from "@/lib/utils";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { page_routes } from "@/lib/routes-config";
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect, useMemo, useState, useRef } from "react"
|
||||
import { ArrowUpIcon, ArrowDownIcon, CornerDownLeftIcon, FileTextIcon } from "lucide-react"
|
||||
import Anchor from "./anchor"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { advanceSearch } from "@/lib/search/built-in"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { page_routes } from "@/lib/routes"
|
||||
import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
@@ -14,63 +15,63 @@ import {
|
||||
DialogClose,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
type ContextInfo = {
|
||||
icon: string;
|
||||
description: string;
|
||||
title?: string;
|
||||
};
|
||||
icon: string
|
||||
description: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
type SearchResult = {
|
||||
title: string;
|
||||
href: string;
|
||||
noLink?: boolean;
|
||||
items?: undefined;
|
||||
score?: number;
|
||||
context?: ContextInfo;
|
||||
};
|
||||
title: string
|
||||
href: string
|
||||
noLink?: boolean
|
||||
items?: undefined
|
||||
score?: number
|
||||
context?: ContextInfo
|
||||
}
|
||||
|
||||
const paddingMap = {
|
||||
1: "pl-2",
|
||||
2: "pl-4",
|
||||
3: "pl-10",
|
||||
} as const;
|
||||
} as const
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
isOpen: boolean
|
||||
setIsOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function SearchModal({ isOpen, setIsOpen }: SearchModalProps) {
|
||||
const router = useRouter();
|
||||
const [searchedInput, setSearchedInput] = useState("");
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const router = useRouter()
|
||||
const [searchedInput, setSearchedInput] = useState("")
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSearchedInput("");
|
||||
setSearchedInput("")
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [isOpen])
|
||||
|
||||
const filteredResults = useMemo<SearchResult[]>(() => {
|
||||
const trimmedInput = searchedInput.trim();
|
||||
const trimmedInput = searchedInput.trim()
|
||||
|
||||
if (trimmedInput.length < 3) {
|
||||
return page_routes
|
||||
.filter((route) => !route.href.endsWith('/'))
|
||||
.filter((route) => !route.href.endsWith("/"))
|
||||
.slice(0, 6)
|
||||
.map((route: { title: string; href: string; noLink?: boolean; context?: ContextInfo }) => ({
|
||||
title: route.title,
|
||||
href: route.href,
|
||||
noLink: route.noLink,
|
||||
context: route.context,
|
||||
}));
|
||||
}))
|
||||
}
|
||||
return advanceSearch(trimmedInput) as unknown as SearchResult[];
|
||||
}, [searchedInput]);
|
||||
return advanceSearch(trimmedInput) as unknown as SearchResult[]
|
||||
}, [searchedInput])
|
||||
|
||||
// useEffect(() => {
|
||||
// setSelectedIndex(0);
|
||||
@@ -78,39 +79,39 @@ export function SearchModal({ isOpen, setIsOpen }: SearchModalProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const handleNavigation = (event: KeyboardEvent) => {
|
||||
if (!isOpen || filteredResults.length === 0) return;
|
||||
if (!isOpen || filteredResults.length === 0) return
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % filteredResults.length);
|
||||
event.preventDefault()
|
||||
setSelectedIndex((prev) => (prev + 1) % filteredResults.length)
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + filteredResults.length) % filteredResults.length);
|
||||
event.preventDefault()
|
||||
setSelectedIndex((prev) => (prev - 1 + filteredResults.length) % filteredResults.length)
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const selectedItem = filteredResults[selectedIndex];
|
||||
event.preventDefault()
|
||||
const selectedItem = filteredResults[selectedIndex]
|
||||
if (selectedItem) {
|
||||
router.push(selectedItem.href);
|
||||
setIsOpen(false);
|
||||
router.push(`/docs${selectedItem.href}`)
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleNavigation);
|
||||
return () => window.removeEventListener("keydown", handleNavigation);
|
||||
}, [isOpen, filteredResults, selectedIndex, router, setIsOpen]);
|
||||
window.addEventListener("keydown", handleNavigation)
|
||||
return () => window.removeEventListener("keydown", handleNavigation)
|
||||
}, [isOpen, filteredResults, selectedIndex, router, setIsOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (itemRefs.current[selectedIndex]) {
|
||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
})
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
}, [selectedIndex])
|
||||
|
||||
return (
|
||||
<DialogContent className="p-0 max-w-[650px] sm:top-[38%] top-[45%] !rounded-md">
|
||||
<DialogContent className="rounded-md! top-[45%] max-w-[650px] p-0 sm:top-[38%]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="sr-only">Search Documentation</DialogTitle>
|
||||
<DialogDescription className="sr-only">Search through the documentation</DialogDescription>
|
||||
@@ -119,84 +120,81 @@ export function SearchModal({ isOpen, setIsOpen }: SearchModalProps) {
|
||||
<input
|
||||
value={searchedInput}
|
||||
onChange={(e) => {
|
||||
setSearchedInput(e.target.value);
|
||||
setSelectedIndex(0);
|
||||
setSearchedInput(e.target.value)
|
||||
setSelectedIndex(0)
|
||||
}}
|
||||
placeholder="Type something to search..."
|
||||
autoFocus
|
||||
className="h-14 px-6 bg-transparent border-b text-[14px] outline-none w-full"
|
||||
className="h-14 w-full border-b bg-transparent px-6 text-[14px] outline-none"
|
||||
aria-label="Search documentation"
|
||||
/>
|
||||
|
||||
{filteredResults.length == 0 && searchedInput && (
|
||||
<p className="text-muted-foreground mx-auto mt-2 text-sm">
|
||||
No results found for{" "}
|
||||
<span className="text-primary">{`"${searchedInput}"`}</span>
|
||||
No results found for <span className="text-primary">{`"${searchedInput}"`}</span>
|
||||
</p>
|
||||
)}
|
||||
<ScrollArea className="max-h-[400px] overflow-y-auto">
|
||||
<div className="flex flex-col items-start overflow-y-auto sm:px-2 px-1 pb-4">
|
||||
<div className="flex flex-col items-start overflow-y-auto px-1 pb-4 sm:px-2">
|
||||
{filteredResults.map((item, index) => {
|
||||
const level = (item.href.split("/").slice(1).length - 1) as keyof typeof paddingMap;
|
||||
const paddingClass = paddingMap[level] || 'pl-2';
|
||||
const isActive = index === selectedIndex;
|
||||
const level = (item.href.split("/").slice(1).length - 1) as keyof typeof paddingMap
|
||||
const paddingClass = paddingMap[level] || "pl-2"
|
||||
const isActive = index === selectedIndex
|
||||
|
||||
return (
|
||||
<DialogClose key={item.href} asChild>
|
||||
<Anchor
|
||||
ref={(el) => {
|
||||
itemRefs.current[index] = el as HTMLDivElement | null;
|
||||
itemRefs.current[index] = el as HTMLDivElement | null
|
||||
}}
|
||||
className={cn(
|
||||
"dark:hover:bg-accent/15 hover:bg-accent/10 w-full px-3 rounded-sm text-sm flex items-center gap-2.5",
|
||||
"dark:hover:bg-accent/15 hover:bg-accent/10 flex w-full items-center gap-2.5 rounded-sm px-3 text-sm",
|
||||
isActive && "bg-primary/20 dark:bg-primary/30",
|
||||
paddingClass
|
||||
)}
|
||||
href={item.href}
|
||||
href={`/docs${item.href}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center w-full h-full py-3 gap-1.5 px-2 justify-between",
|
||||
"flex h-full w-full items-center justify-between gap-1.5 px-2 py-3",
|
||||
level > 1 && "border-l pl-4"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<FileTextIcon className="h-[1.1rem] w-[1.1rem] mr-1" />
|
||||
<FileTextIcon className="mr-1 h-[1.1rem] w-[1.1rem]" />
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="hidden md:flex items-center text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground hidden items-center text-xs md:flex">
|
||||
<span>Return</span>
|
||||
<CornerDownLeftIcon className="h-3 w-3 ml-1" />
|
||||
<CornerDownLeftIcon className="ml-1 h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Anchor>
|
||||
</DialogClose>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<DialogFooter className="md:flex md:justify-start hidden h-14 px-6 bg-transparent border-t text-[14px] outline-none">
|
||||
<DialogFooter className="hidden h-14 border-t bg-transparent px-6 text-[14px] outline-none md:flex md:justify-start">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2">
|
||||
<ArrowUpIcon className="w-3 h-3" />
|
||||
<span className="dark:bg-accent/15 rounded border bg-slate-200 p-2">
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2">
|
||||
<ArrowDownIcon className="w-3 h-3" />
|
||||
<span className="dark:bg-accent/15 rounded border bg-slate-200 p-2">
|
||||
<ArrowDownIcon className="h-3 w-3" />
|
||||
</span>
|
||||
<p className="text-muted-foreground">to navigate</p>
|
||||
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2">
|
||||
<CornerDownLeftIcon className="w-3 h-3" />
|
||||
<span className="dark:bg-accent/15 rounded border bg-slate-200 p-2">
|
||||
<CornerDownLeftIcon className="h-3 w-3" />
|
||||
</span>
|
||||
<p className="text-muted-foreground">to select</p>
|
||||
<span className="dark:bg-accent/15 bg-slate-200 border rounded px-2 py-1">
|
||||
esc
|
||||
</span>
|
||||
<span className="dark:bg-accent/15 rounded border bg-slate-200 px-2 py-1">esc</span>
|
||||
<p className="text-muted-foreground">to close</p>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { CommandIcon, SearchIcon } from "lucide-react";
|
||||
import { DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { CommandIcon, SearchIcon } from "lucide-react"
|
||||
import { DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function SearchTrigger() {
|
||||
interface SearchTriggerProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SearchTrigger({ className }: SearchTriggerProps) {
|
||||
return (
|
||||
<DialogTrigger asChild>
|
||||
<div className="relative flex-1 cursor-pointer max-w-[140px]">
|
||||
<div className={cn("relative flex-1 cursor-pointer", className)}>
|
||||
<div className="flex items-center">
|
||||
<div className="md:hidden p-2 -ml-2">
|
||||
<SearchIcon className="h-5 w-5 text-muted-foreground" />
|
||||
<div className="-ml-2 block p-2 lg:hidden">
|
||||
<SearchIcon className="text-muted-foreground h-6 w-6" />
|
||||
</div>
|
||||
<div className="hidden md:block w-full">
|
||||
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<div className="hidden w-full lg:block">
|
||||
<SearchIcon className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
className="w-full rounded-full dark:bg-background/95 bg-background border h-9 pl-10 pr-0 sm:pr-4 text-sm shadow-sm overflow-ellipsis"
|
||||
className="dark:bg-background/95 bg-background h-9 w-full overflow-ellipsis rounded-full border pl-10 pr-0 text-sm shadow-sm sm:pr-4"
|
||||
placeholder="Search"
|
||||
readOnly // This input is for display only
|
||||
/>
|
||||
<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" />
|
||||
<div className="dark:bg-accent bg-accent absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 rounded-full px-2 py-0.5 font-mono text-xs font-medium text-white">
|
||||
<CommandIcon className="h-3 w-3" />
|
||||
<span>K</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export function Sponsor() {
|
||||
// Safely get sponsor data with optional chaining and default values
|
||||
const sponsor = docuConfig?.sponsor || {};
|
||||
const item = sponsor?.item;
|
||||
|
||||
|
||||
// Return null if required fields are missing
|
||||
if (!item?.url || !item?.image || !item?.title) {
|
||||
return null;
|
||||
@@ -106,7 +106,7 @@ export function Sponsor() {
|
||||
rel="noopener noreferrer"
|
||||
className="flex flex-col justify-center gap-2 p-4 border rounded-lg hover:shadow transition-shadow"
|
||||
>
|
||||
<div className="relative w-8 h-8 flex-shrink-0">
|
||||
<div className="relative w-8 h-8 shrink-0">
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
|
||||
@@ -17,9 +17,9 @@ export function ModeToggle() {
|
||||
// Jika belum mounted, jangan render apapun untuk menghindari mismatch
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-border bg-background/50 p-1">
|
||||
<div className="rounded-full p-1 w-8 h-8" />
|
||||
<div className="rounded-full p-1 w-8 h-8" />
|
||||
<div className="flex items-center gap-1 rounded-full border border-border bg-background/50 p-0.5">
|
||||
<div className="rounded-full p-0 w-1 h-1" />
|
||||
<div className="rounded-full p-0 w-1 h-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,29 +43,29 @@ export function ModeToggle() {
|
||||
type="single"
|
||||
value={activeTheme}
|
||||
onValueChange={handleToggle}
|
||||
className="flex items-center gap-1 rounded-full border border-border bg-background/50 p-1 transition-all"
|
||||
className="flex items-center gap-1 rounded-full border border-border bg-background/50 p-0.5 transition-all"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="light"
|
||||
size="sm"
|
||||
size="xs"
|
||||
aria-label="Light Mode"
|
||||
className={`rounded-full p-1 transition-all ${activeTheme === "light"
|
||||
className={`rounded-full cursor-pointer p-0.5 transition-all ${activeTheme === "light"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-transparent hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Sun className="h-4 w-4" />
|
||||
<Sun className="h-0.5 w-0.5" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="dark"
|
||||
size="sm"
|
||||
size="xs"
|
||||
aria-label="Dark Mode"
|
||||
className={`rounded-full p-1 transition-all ${activeTheme === "dark"
|
||||
className={`rounded-full cursor-pointer p-0.5 transition-all ${activeTheme === "dark"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-transparent hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Moon className="h-4 w-4" />
|
||||
<Moon className="h-0.5 w-0.5" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
);
|
||||
197
components/TocObserver.tsx
Normal file
197
components/TocObserver.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
"use client"
|
||||
|
||||
import clsx from "clsx"
|
||||
import Link from "next/link"
|
||||
import { useState, useRef, useEffect, useCallback } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { ScrollToTop } from "./ScrollToTop"
|
||||
import { TocItem } from "@/lib/toc"
|
||||
|
||||
interface TocObserverProps {
|
||||
data: TocItem[]
|
||||
activeId?: string | null
|
||||
onActiveIdChange?: (id: string | null) => void
|
||||
}
|
||||
|
||||
export default function TocObserver({
|
||||
data,
|
||||
activeId: externalActiveId,
|
||||
onActiveIdChange,
|
||||
}: TocObserverProps) {
|
||||
const itemRefs = useRef<Map<string, HTMLAnchorElement>>(new Map())
|
||||
|
||||
const activeId = externalActiveId ?? null
|
||||
|
||||
const handleLinkClick = useCallback(
|
||||
(id: string) => {
|
||||
onActiveIdChange?.(id)
|
||||
},
|
||||
[onActiveIdChange]
|
||||
)
|
||||
|
||||
// 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)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const container = document.getElementById("scroll-container") || window
|
||||
|
||||
container.addEventListener("scroll", handleScroll, { passive: true })
|
||||
|
||||
// Initial calculation
|
||||
handleScroll()
|
||||
|
||||
return () => container.removeEventListener("scroll", handleScroll)
|
||||
}, [activeId])
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="text-foreground/70 hover:text-foreground relative text-sm transition-colors">
|
||||
<div className="flex flex-col gap-0">
|
||||
{data.map(({ href, level, text }, index) => {
|
||||
const id = href.slice(1)
|
||||
const isActive = activeId === id
|
||||
const indent = level > 1 ? (level - 1) * 20 : 0
|
||||
// Prefix with underscore to indicate intentionally unused
|
||||
const _isParent = hasChildren(id, level)
|
||||
const _isLastInLevel = index === data.length - 1 || data[index + 1].level <= level
|
||||
|
||||
return (
|
||||
<div key={href} className="relative">
|
||||
{/* Simple L-shaped connector */}
|
||||
{level > 1 && (
|
||||
<div
|
||||
className={clsx("absolute top-0 h-full w-6", {
|
||||
"left-[6px]": indent === 20, // Level 2
|
||||
"left-[22px]": indent === 40, // Level 3
|
||||
"left-[38px]": indent === 60, // Level 4
|
||||
})}
|
||||
>
|
||||
{/* Vertical line */}
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute left-0 top-0 h-full w-px",
|
||||
isActive
|
||||
? "bg-primary/20 dark:bg-primary/30"
|
||||
: "bg-border/50 dark:bg-border/50"
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
className="bg-primary absolute left-0 top-0 h-full w-full 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 dark:bg-primary/30"
|
||||
: "bg-border/50 dark:bg-border/50"
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
className="bg-primary dark:bg-accent absolute left-0 top-0 h-full w-full 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 font-medium": isActive,
|
||||
"text-muted-foreground hover:text-foreground dark:hover:text-foreground/90":
|
||||
!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 flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<div
|
||||
className={clsx(
|
||||
"relative z-10 h-1.5 w-1.5 rounded-full transition-all duration-300",
|
||||
{
|
||||
"bg-primary dark:bg-primary/90 scale-100": isActive,
|
||||
"bg-muted-foreground/30 dark:bg-muted-foreground/30 group-hover:bg-primary/50 dark:group-hover:bg-primary/50 scale-75 group-hover:scale-100":
|
||||
!isActive,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
className="bg-primary/20 dark:bg-primary/30 absolute inset-0 rounded-full"
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
// Create a context to check if a component is inside an accordion group
|
||||
export const AccordionGroupContext = createContext<{ inGroup: boolean } | null>(null);
|
||||
@@ -1,37 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ROUTES, EachRoute } from "@/lib/routes-config";
|
||||
import SubLink from "./sublink";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DocsMenuProps {
|
||||
isSheet?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function DocsMenu({ isSheet = false, className = "" }: DocsMenuProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (!pathname.startsWith("/docs")) return null;
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Documentation navigation"
|
||||
className={cn("transition-all duration-200", className)}
|
||||
>
|
||||
<ul className="flex flex-col gap-1.5 py-4">
|
||||
{ROUTES.map((route, index) => (
|
||||
<li key={route.title + index}>
|
||||
<SubLink
|
||||
{...route}
|
||||
href={`${route.href}`}
|
||||
level={0}
|
||||
isSheet={isSheet}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ModeToggle } from "@/components/theme-toggle";
|
||||
import { ModeToggle } from "@/components/ThemeToggle";
|
||||
import docuData from "@/docu.json";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
|
||||
@@ -20,21 +20,25 @@ const docuConfig = docuData as {
|
||||
footer: FooterConfig;
|
||||
};
|
||||
|
||||
export function Footer() {
|
||||
interface FooterProps {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function Footer({ id }: FooterProps) {
|
||||
const { footer } = docuConfig;
|
||||
return (
|
||||
<footer className="w-full py-8 border-t bg-background">
|
||||
<footer id={id} className="w-full py-8 border-t bg-background">
|
||||
<div className="container flex flex-col lg:flex-row items-center justify-between text-sm">
|
||||
<div className="flex flex-col items-center lg:items-start justify-start gap-4 w-full lg:w-3/5 text-center lg:text-left">
|
||||
<p className="text-muted-foreground">
|
||||
Copyright © {new Date().getFullYear()} {footer.copyright} - <MadeWith />
|
||||
</p>
|
||||
<div className="flex items-center justify-center lg:justify-start gap-6 mt-2 w-full">
|
||||
<FooterButtons />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Copyright © {new Date().getFullYear()} {footer.copyright} - <MadeWith />
|
||||
</p>
|
||||
<div className="flex items-center justify-center lg:justify-start gap-6 mt-2 w-full">
|
||||
<FooterButtons />
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden lg:flex items-center justify-end lg:w-2/5">
|
||||
<ModeToggle />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -79,9 +83,9 @@ export function MadeWith() {
|
||||
<span className="text-muted-foreground">Made with </span>
|
||||
<span className="text-primary">
|
||||
<Link href="https://www.docubook.pro" target="_blank" rel="noopener noreferrer" className="underline underline-offset-2 text-muted-foreground">
|
||||
DocuBook
|
||||
DocuBook
|
||||
</Link>
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,101 +1,69 @@
|
||||
"use client"
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Logo, NavMenu } from "@/components/navbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LayoutGrid, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import DocsMenu from "@/components/docs-menu";
|
||||
import { ModeToggle } from "@/components/theme-toggle";
|
||||
import ContextPopover from "@/components/context-popover";
|
||||
|
||||
// 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="cursor-pointer hover:bg-transparent hover:text-inherit border-none text-muted-foreground"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen size={18} />
|
||||
) : (
|
||||
<PanelLeftClose size={18} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTrigger } from "@/components/ui/sheet"
|
||||
import { Logo, NavMenu } from "@/components/navbar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { PanelRight } from "lucide-react"
|
||||
import { DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import DocsMenu from "@/components/DocsMenu"
|
||||
import { ModeToggle } from "@/components/ThemeToggle"
|
||||
import ContextPopover from "@/components/ContextPopover"
|
||||
import Search from "@/components/SearchBox"
|
||||
|
||||
export function Leftbar() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const toggleCollapse = () => setCollapsed(prev => !prev);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`sticky lg:flex hidden top-16 h-[calc(100vh-4rem)] border-r bg-background transition-all duration-300
|
||||
${collapsed ? "w-[24px]" : "w-[280px]"} flex flex-col pr-2`}
|
||||
>
|
||||
<ToggleButton collapsed={collapsed} onToggle={toggleCollapse} />
|
||||
{/* Scrollable Content */}
|
||||
<ScrollArea className="flex-1 px-0.5 pb-4">
|
||||
{!collapsed && (
|
||||
<div className="space-y-2">
|
||||
<ContextPopover />
|
||||
<DocsMenu />
|
||||
</div>
|
||||
)}
|
||||
<aside className="sticky top-0 hidden h-screen w-[280px] shrink-0 flex-col lg:flex">
|
||||
{/* Logo */}
|
||||
<div className="flex h-14 shrink-0 items-center px-5">
|
||||
<Logo />
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 px-4 pb-4">
|
||||
<Search className="min-w-[250px] max-w-[250px]" />
|
||||
</div>
|
||||
|
||||
{/* Scrollable Navigation */}
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="space-y-2">
|
||||
<ContextPopover />
|
||||
<DocsMenu />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Bottom: Theme Toggle */}
|
||||
<div className="flex px-4 py-3">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function SheetLeftbar() {
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="max-lg:flex hidden">
|
||||
<LayoutGrid />
|
||||
<Button variant="ghost" size="icon" className="hidden max-md:flex">
|
||||
<PanelRight className="text-muted-foreground h-6 w-6 shrink-0" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="flex flex-col gap-4 px-0" side="left">
|
||||
<SheetContent className="flex flex-col gap-4 px-0" side="right">
|
||||
<DialogTitle className="sr-only">Navigation Menu</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Main navigation menu with links to different sections
|
||||
</DialogDescription>
|
||||
<SheetHeader>
|
||||
<SheetClose className="px-5" asChild>
|
||||
<span className="px-2"><Logo /></span>
|
||||
<SheetClose className="px-4" asChild>
|
||||
<div className="flex items-center justify-between">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</SheetClose>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-4 overflow-y-auto">
|
||||
<div className="flex flex-col gap-2.5 mt-3 mx-2 px-5">
|
||||
<NavMenu isSheet />
|
||||
</div>
|
||||
<div className="mx-2 px-5 space-y-2">
|
||||
<ContextPopover />
|
||||
<DocsMenu isSheet />
|
||||
</div>
|
||||
<div className="flex w-2/4 px-5">
|
||||
<ModeToggle />
|
||||
<div className="mx-2 mt-3 flex flex-col gap-2.5 px-5">
|
||||
<NavMenu />
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
21
components/markdown/AccordionContext.tsx
Normal file
21
components/markdown/AccordionContext.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, useState, useId } from "react"
|
||||
|
||||
type AccordionGroupContextType = {
|
||||
inGroup: boolean
|
||||
groupId: string
|
||||
openTitle: string | null
|
||||
setOpenTitle: (title: string | null) => void
|
||||
}
|
||||
|
||||
export const AccordionGroupContext = createContext<AccordionGroupContextType | null>(null)
|
||||
|
||||
export function AccordionGroupProvider({ children }: { children: React.ReactNode }) {
|
||||
const [openTitle, setOpenTitle] = useState<string | null>(null)
|
||||
const groupId = useId()
|
||||
|
||||
return (
|
||||
<AccordionGroupContext.Provider value={{ inGroup: true, groupId, openTitle, setOpenTitle }}>
|
||||
{children}
|
||||
</AccordionGroupContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import React, { ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { AccordionGroupContext } from "@/components/contexts/AccordionContext";
|
||||
import React, { ReactNode } from "react"
|
||||
import clsx from "clsx"
|
||||
import { AccordionGroupProvider } from "@/components/markdown/AccordionContext"
|
||||
|
||||
interface AccordionGroupProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AccordionGroup: React.FC<AccordionGroupProps> = ({ children, className }) => {
|
||||
|
||||
return (
|
||||
// Wrap all children with the AccordionGroupContext.Provider
|
||||
// so that any nested accordions know they are inside a group.
|
||||
// This enables group-specific behavior in child components.
|
||||
<AccordionGroupContext.Provider value={{ inGroup: true }}>
|
||||
<div
|
||||
className={clsx(
|
||||
"border rounded-lg overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionGroupContext.Provider>
|
||||
);
|
||||
};
|
||||
<AccordionGroupProvider>
|
||||
<div className={clsx("overflow-hidden rounded-lg border", className)}>{children}</div>
|
||||
</AccordionGroupProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccordionGroup;
|
||||
export default AccordionGroup
|
||||
|
||||
@@ -1,62 +1,61 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { ReactNode, useState, useContext } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import * as Icons from "lucide-react";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AccordionGroupContext } from '@/components/contexts/AccordionContext';
|
||||
import { ReactNode, useContext, useState } from "react"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
import * as Icons from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { AccordionGroupContext } from "@/components/markdown/AccordionContext"
|
||||
|
||||
type AccordionProps = {
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
icon?: keyof typeof Icons;
|
||||
};
|
||||
title: string
|
||||
children?: ReactNode
|
||||
icon?: keyof typeof Icons
|
||||
}
|
||||
|
||||
const Accordion: React.FC<AccordionProps> = ({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
icon,
|
||||
}: AccordionProps) => {
|
||||
const groupContext = useContext(AccordionGroupContext);
|
||||
const isInGroup = groupContext?.inGroup === true;
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const Icon = icon ? (Icons[icon] as React.FC<{ className?: string }>) : null;
|
||||
const Accordion: React.FC<AccordionProps> = ({ title, children, icon }: AccordionProps) => {
|
||||
const groupContext = useContext(AccordionGroupContext)
|
||||
const isInGroup = groupContext?.inGroup === true
|
||||
const groupOpen = groupContext?.openTitle === title
|
||||
const setGroupOpen = groupContext?.setOpenTitle
|
||||
const [localOpen, setLocalOpen] = useState(false)
|
||||
|
||||
// The main wrapper div for the accordion.
|
||||
// All styling logic for the accordion container is handled here.
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Style for STANDALONE: full card with border & shadow
|
||||
!isInGroup && "border rounded-lg shadow-sm",
|
||||
// Style for IN GROUP: only a bottom border separator
|
||||
isInGroup && "border-b last:border-b-0 border-border"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 w-full px-4 h-12 transition-colors bg-muted/40 dark:bg-muted/20 hover:bg-muted/70 dark:hover:bg-muted/70"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"w-4 h-4 text-muted-foreground transition-transform duration-200 flex-shrink-0",
|
||||
isOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
{Icon && <Icon className="text-foreground w-4 h-4 flex-shrink-0" />}
|
||||
<h3 className="font-medium text-base text-foreground !m-0 leading-none">{title}</h3>
|
||||
</button>
|
||||
const isOpen = isInGroup ? groupOpen : localOpen
|
||||
|
||||
{isOpen && (
|
||||
<div className="px-4 py-3 dark:bg-muted/10 bg-muted/15">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const handleToggle = () => {
|
||||
if (isInGroup && setGroupOpen) {
|
||||
setGroupOpen(groupOpen ? null : title)
|
||||
} else {
|
||||
setLocalOpen(!localOpen)
|
||||
}
|
||||
}
|
||||
|
||||
export default Accordion;
|
||||
const Icon = icon ? (Icons[icon] as React.FC<{ className?: string }>) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
!isInGroup && "rounded-lg border shadow-sm",
|
||||
isInGroup && "border-border border-b last:border-b-0"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="bg-muted/40 dark:bg-muted/20 hover:bg-muted/70 dark:hover:bg-muted/70 flex w-full cursor-pointer items-center gap-2 px-4 py-3 text-start transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200",
|
||||
isOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
{Icon && <Icon className="text-foreground h-4 w-4 shrink-0" />}
|
||||
<h3 className="text-foreground m-0! text-base font-medium">{title}</h3>
|
||||
</button>
|
||||
|
||||
{isOpen && <div className="dark:bg-muted/10 bg-muted/15 px-4 py-3">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Accordion
|
||||
|
||||
@@ -24,13 +24,13 @@ const Card: React.FC<CardProps> = ({ title, icon, href, horizontal, children, cl
|
||||
"bg-card text-card-foreground border-border",
|
||||
"hover:bg-accent/5 hover:border-accent/30",
|
||||
"flex gap-2",
|
||||
horizontal ? "flex-row items-center gap-1" : "flex-col space-y-1",
|
||||
horizontal ? "flex-row items-start gap-1" : "flex-col space-y-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="w-5 h-5 text-primary flex-shrink-0" />}
|
||||
<div className="flex-1 min-w-0 my-auto h-full">
|
||||
<span className="text-base font-semibold text-foreground">{title}</span>
|
||||
{Icon && <Icon className={clsx("w-5 h-5 text-primary shrink-0", horizontal && "mt-0.5")} />}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base font-semibold text-foreground leading-6">{title}</div>
|
||||
<div className="text-sm text-muted-foreground -mt-3">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function Copy({ content }: { content: string }) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="border"
|
||||
className="border cursor-copy"
|
||||
size="xs"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
|
||||
@@ -24,7 +24,7 @@ const FileComponent = ({ name }: FileProps) => {
|
||||
tabIndex={-1}
|
||||
>
|
||||
<FileIcon className={`
|
||||
h-3.5 w-3.5 flex-shrink-0 transition-colors
|
||||
h-3.5 w-3.5 shrink-0 transition-colors
|
||||
${isHovered ? 'text-accent' : 'text-muted-foreground'}
|
||||
`} />
|
||||
<span className="font-mono text-sm text-foreground truncate">{name}</span>
|
||||
@@ -61,7 +61,7 @@ const FolderComponent = ({ name, children }: FileProps) => {
|
||||
{hasChildren ? (
|
||||
<ChevronRight
|
||||
className={`
|
||||
h-3.5 w-3.5 flex-shrink-0 transition-transform duration-200
|
||||
h-3.5 w-3.5 shrink-0 transition-transform duration-200
|
||||
${isOpen ? 'rotate-90' : ''}
|
||||
${isHovered ? 'text-foreground/70' : 'text-muted-foreground'}
|
||||
`}
|
||||
@@ -71,12 +71,12 @@ const FolderComponent = ({ name, children }: FileProps) => {
|
||||
)}
|
||||
{isOpen ? (
|
||||
<FolderOpen className={`
|
||||
h-4 w-4 flex-shrink-0 transition-colors
|
||||
h-4 w-4 shrink-0 transition-colors
|
||||
${isHovered ? 'text-accent' : 'text-muted-foreground'}
|
||||
`} />
|
||||
) : (
|
||||
<FolderIcon className={`
|
||||
h-4 w-4 flex-shrink-0 transition-colors
|
||||
h-4 w-4 shrink-0 transition-colors
|
||||
${isHovered ? 'text-accent/80' : 'text-muted-foreground/80'}
|
||||
`} />
|
||||
)}
|
||||
|
||||
@@ -1,29 +1,129 @@
|
||||
import { ComponentProps } from "react";
|
||||
"use client";
|
||||
|
||||
import { ComponentProps, useState, useEffect } from "react";
|
||||
import NextImage from "next/image";
|
||||
import { createPortal } from "react-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, ZoomIn } from "lucide-react";
|
||||
|
||||
type Height = ComponentProps<typeof NextImage>["height"];
|
||||
type Width = ComponentProps<typeof NextImage>["width"];
|
||||
|
||||
type ImageProps = Omit<ComponentProps<"img">, "src"> & {
|
||||
src?: ComponentProps<typeof NextImage>["src"];
|
||||
src?: ComponentProps<typeof NextImage>["src"];
|
||||
};
|
||||
|
||||
export default function Image({
|
||||
src,
|
||||
alt = "alt",
|
||||
width = 800,
|
||||
height = 350,
|
||||
...props
|
||||
src,
|
||||
alt = "alt",
|
||||
width = 800,
|
||||
height = 350,
|
||||
...props
|
||||
}: ImageProps) {
|
||||
if (!src) return null;
|
||||
return (
|
||||
<NextImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width as Width}
|
||||
height={height as Height}
|
||||
quality={40}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Lock scroll when open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
// Check for Escape key
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", handleEsc);
|
||||
return () => {
|
||||
document.body.style.overflow = "auto";
|
||||
window.removeEventListener("keydown", handleEsc);
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!src) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="relative group cursor-zoom-in my-6 w-full flex justify-center rounded-lg"
|
||||
onClick={() => setIsOpen(true)}
|
||||
aria-label="Zoom image"
|
||||
>
|
||||
<span className="absolute inset-0 bg-black/0 group-hover:bg-black/5 transition-colors z-10 flex items-center justify-center opacity-0 group-hover:opacity-100 rounded-lg">
|
||||
<ZoomIn className="w-8 h-8 text-white drop-shadow-md" />
|
||||
</span>
|
||||
<NextImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width as Width}
|
||||
height={height as Height}
|
||||
quality={85}
|
||||
className="w-full h-auto rounded-lg transition-transform duration-300 group-hover:scale-[1.01]"
|
||||
{...props}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<Portal>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-99999 flex items-center justify-center bg-black/90 backdrop-blur-md p-4 md:p-10 cursor-zoom-out"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
className="absolute top-4 right-4 z-50 p-2 text-white/70 hover:text-white bg-black/20 hover:bg-white/10 rounded-full transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{/* Image Container */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="relative max-w-7xl w-full h-full flex items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="relative w-full h-full flex items-center justify-center" onClick={() => setIsOpen(false)}>
|
||||
<NextImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={1920}
|
||||
height={1080}
|
||||
className="object-contain max-h-[90vh] w-auto h-auto rounded-md shadow-2xl"
|
||||
quality={95}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Caption */}
|
||||
{alt && alt !== "alt" && (
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
className="absolute bottom-6 left-1/2 -translate-x-1/2 bg-black/60 text-white px-4 py-2 rounded-full text-sm font-medium backdrop-blur-md border border-white/10"
|
||||
>
|
||||
{alt}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
</motion.div>
|
||||
</Portal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Portal = ({ children }: { children: React.ReactNode }) => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return createPortal(children, document.body);
|
||||
};
|
||||
|
||||
@@ -1,52 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import clsx from "clsx";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import {
|
||||
Info,
|
||||
AlertTriangle,
|
||||
ShieldAlert,
|
||||
CheckCircle,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
type NoteProps = PropsWithChildren & {
|
||||
title?: string;
|
||||
type?: "note" | "danger" | "warning" | "success";
|
||||
};
|
||||
const noteVariants = cva(
|
||||
"relative w-full rounded-lg border border-l-4 p-4 mb-4 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
note: "bg-muted/30 border-border border-l-primary/50 text-foreground [&>svg]:text-primary",
|
||||
danger: "border-destructive/20 border-l-destructive/60 bg-destructive/5 text-destructive [&>svg]:text-destructive dark:border-destructive/30",
|
||||
warning: "border-orange-500/20 border-l-orange-500/60 bg-orange-500/5 text-orange-600 dark:text-orange-400 [&>svg]:text-orange-600 dark:[&>svg]:text-orange-400",
|
||||
success: "border-emerald-500/20 border-l-emerald-500/60 bg-emerald-500/5 text-emerald-600 dark:text-emerald-400 [&>svg]:text-emerald-600 dark:[&>svg]:text-emerald-400",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "note",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const iconMap = {
|
||||
note: <Info size={16} className="text-blue-500" />,
|
||||
danger: <ShieldAlert size={16} className="text-red-500" />,
|
||||
warning: <AlertTriangle size={16} className="text-orange-500" />,
|
||||
success: <CheckCircle size={16} className="text-green-500" />,
|
||||
note: Info,
|
||||
danger: ShieldAlert,
|
||||
warning: AlertTriangle,
|
||||
success: CheckCircle2,
|
||||
};
|
||||
|
||||
interface NoteProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof noteVariants> {
|
||||
title?: string;
|
||||
type?: "note" | "danger" | "warning" | "success";
|
||||
}
|
||||
|
||||
export default function Note({
|
||||
children,
|
||||
className,
|
||||
title = "Note",
|
||||
type = "note",
|
||||
children,
|
||||
...props
|
||||
}: NoteProps) {
|
||||
const noteClassNames = clsx({
|
||||
"dark:bg-stone-950/25 bg-stone-50": type === "note",
|
||||
"dark:bg-red-950 bg-red-100 border-red-200 dark:border-red-900":
|
||||
type === "danger",
|
||||
"bg-orange-50 border-orange-200 dark:border-orange-900 dark:bg-orange-900/50":
|
||||
type === "warning",
|
||||
"dark:bg-green-950 bg-green-100 border-green-200 dark:border-green-900":
|
||||
type === "success",
|
||||
});
|
||||
const Icon = iconMap[type] || Info;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border rounded-md px-5 pb-0.5 mt-5 mb-7 text-sm tracking-wide",
|
||||
noteClassNames
|
||||
)}
|
||||
className={cn(noteVariants({ variant: type }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2 font-bold -mb-2.5 pt-6">
|
||||
{iconMap[type]}
|
||||
<span className="text-base">{title}:</span>
|
||||
<Icon className="h-5 w-5" />
|
||||
<div className="pl-8">
|
||||
<h5 className="mb-1 font-medium leading-none tracking-tight">
|
||||
{title}
|
||||
</h5>
|
||||
<div className="text-sm [&_p]:leading-relaxed opacity-90">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
SiSwift,
|
||||
SiKotlin,
|
||||
SiHtml5,
|
||||
SiCss3,
|
||||
SiCss,
|
||||
SiSass,
|
||||
SiPostgresql,
|
||||
SiGraphql,
|
||||
@@ -68,7 +68,7 @@ const LanguageIcon = ({ lang }: { lang: string }) => {
|
||||
js: <SiJavascript {...iconProps} />,
|
||||
javascript: <SiJavascript {...iconProps} />,
|
||||
html: <SiHtml5 {...iconProps} />,
|
||||
css: <SiCss3 {...iconProps} />,
|
||||
css: <SiCss {...iconProps} />,
|
||||
scss: <SiSass {...iconProps} />,
|
||||
sass: <SiSass {...iconProps} />,
|
||||
};
|
||||
|
||||
@@ -12,25 +12,29 @@ 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">
|
||||
<div className="flex items-center gap-3 mt-6 mb-2">
|
||||
<div
|
||||
id={version}
|
||||
className="inline-flex items-center rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-sm font-semibold text-primary transition-colors hover:bg-primary/15 scroll-m-20 backdrop-blur-sm"
|
||||
>
|
||||
v{version}
|
||||
</div>
|
||||
{date && (
|
||||
<div className="flex items-center gap-3 text-sm font-medium text-muted-foreground">
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground/30"></span>
|
||||
<time dateTime={date}>
|
||||
{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>
|
||||
</time>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-foreground/90 mb-6 mt-0!">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="space-y-8">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ const Tooltip: React.FC<TooltipProps> = ({ text, tip }) => {
|
||||
{text}
|
||||
</span>
|
||||
{visible && (
|
||||
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-64 bg-popover text-popover-foreground text-sm p-3 rounded-md shadow-lg border border-border/50 break-words text-left z-50">
|
||||
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-64 bg-popover text-popover-foreground text-sm p-3 rounded-md shadow-lg border border-border/50 wrap-break-word text-left z-50">
|
||||
{tip}
|
||||
<span className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-popover rotate-45 border-b border-r border-border/50 -z-10" />
|
||||
</span>
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { List, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import TocObserver from "./toc-observer";
|
||||
import * as React from "react";
|
||||
import { useRef, useMemo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Button } from "./ui/button";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useActiveSection } from "@/hooks";
|
||||
import { TocItem } from "@/lib/toc";
|
||||
|
||||
interface MobTocProps {
|
||||
tocs: TocItem[];
|
||||
}
|
||||
|
||||
const useClickOutside = (ref: React.RefObject<HTMLElement | null>, callback: () => void) => {
|
||||
const handleClick = React.useCallback((event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
callback();
|
||||
}
|
||||
}, [ref, callback]);
|
||||
|
||||
React.useEffect(() => {
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
};
|
||||
}, [handleClick]);
|
||||
};
|
||||
|
||||
export default function MobToc({ tocs }: MobTocProps) {
|
||||
const pathname = usePathname();
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const tocRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Use custom hooks
|
||||
const { activeId, setActiveId } = useActiveSection(tocs);
|
||||
|
||||
// Only show on /docs pages
|
||||
const isDocsPage = useMemo(() => pathname?.startsWith('/docs'), [pathname]);
|
||||
|
||||
// Toggle expanded state
|
||||
const toggleExpanded = React.useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// Close TOC when clicking outside
|
||||
useClickOutside(tocRef, () => {
|
||||
if (isExpanded) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle body overflow when TOC is expanded
|
||||
React.useEffect(() => {
|
||||
if (isExpanded) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isExpanded]);
|
||||
|
||||
// Don't render anything if not on docs page or no TOC items
|
||||
if (!isDocsPage || !tocs?.length) return null;
|
||||
|
||||
const chevronIcon = isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
ref={tocRef}
|
||||
className="lg:hidden fixed top-16 left-0 right-0 z-50"
|
||||
initial={{ y: -100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -100, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
<div className="w-full bg-background/95 backdrop-blur-sm border-b border-stone-200 dark:border-stone-800 shadow-sm">
|
||||
<div className="sm:px-8 px-4 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-between h-auto py-2 px-2 -mx-1 rounded-md hover:bg-transparent hover:text-inherit"
|
||||
onClick={toggleExpanded}
|
||||
aria-label={isExpanded ? 'Collapse table of contents' : 'Expand table of contents'}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<List className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="font-medium text-sm">On this page</span>
|
||||
</div>
|
||||
{chevronIcon}
|
||||
</Button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
ref={contentRef}
|
||||
className="mt-2 pb-2 max-h-[60vh] overflow-y-auto px-1 -mx-1"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
<TocObserver
|
||||
data={tocs}
|
||||
activeId={activeId}
|
||||
onActiveIdChange={setActiveId}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +1,158 @@
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import Search from "@/components/search";
|
||||
import Anchor from "@/components/anchor";
|
||||
import { SheetLeftbar } from "@/components/leftbar";
|
||||
import { SheetClose } from "@/components/ui/sheet";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import docuConfig from "@/docu.json"; // Import JSON
|
||||
"use client"
|
||||
|
||||
export function Navbar() {
|
||||
import { ArrowUpRight, ChevronDown, ChevronUp } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
import Search from "@/components/SearchBox"
|
||||
import Anchor from "@/components/anchor"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import docuConfig from "@/docu.json"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useState, useCallback } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { ModeToggle } from "@/components/ThemeToggle"
|
||||
|
||||
interface NavbarProps {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function Navbar({ id }: NavbarProps) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
|
||||
const toggleMenu = useCallback(() => {
|
||||
setIsMenuOpen((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-50 w-full h-16 border-b bg-background">
|
||||
<div className="sm:container mx-auto w-[95vw] h-full flex items-center justify-between md:gap-2">
|
||||
<div className="flex items-center gap-5">
|
||||
<SheetLeftbar />
|
||||
<div className="sticky top-0 z-50 w-full">
|
||||
<nav id={id} className="bg-background h-16 w-full border-b">
|
||||
<div className="mx-auto flex h-full w-[95vw] items-center justify-between sm:container md:gap-2">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="hidden lg:flex">
|
||||
<div className="flex">
|
||||
<Logo />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="items-center hidden gap-4 text-sm font-medium lg:flex text-muted-foreground">
|
||||
<div className="flex items-center gap-0 max-md:flex-row-reverse md:gap-2">
|
||||
<div className="text-muted-foreground hidden items-center gap-4 text-sm font-medium md:flex">
|
||||
<NavMenu />
|
||||
</div>
|
||||
<Separator className="hidden lg:flex my-4 h-9" orientation="vertical" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleMenu}
|
||||
aria-label={isMenuOpen ? "Close navigation menu" : "Open navigation menu"}
|
||||
aria-expanded={isMenuOpen}
|
||||
className="flex items-center gap-1 px-2 text-sm font-medium md:hidden"
|
||||
>
|
||||
{isMenuOpen ? (
|
||||
<ChevronUp className="text-muted-foreground h-6 w-6" />
|
||||
) : (
|
||||
<ChevronDown className="text-muted-foreground h-6 w-6" />
|
||||
)}
|
||||
</Button>
|
||||
<Separator className="my-4 hidden h-9 md:flex" orientation="vertical" />
|
||||
<Search />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
</nav>
|
||||
|
||||
<AnimatePresence>
|
||||
{isMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className="bg-background/95 w-full border-b shadow-sm backdrop-blur-sm md:hidden"
|
||||
>
|
||||
<div className="mx-auto w-[95vw] sm:container">
|
||||
<ul className="flex flex-col py-2">
|
||||
<NavMenuCollapsible onItemClick={() => setIsMenuOpen(false)} />
|
||||
</ul>
|
||||
<div className="flex items-center justify-between border-t px-1 py-3">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Logo() {
|
||||
const { navbar } = docuConfig; // Extract navbar from JSON
|
||||
const { navbar } = docuConfig
|
||||
|
||||
return (
|
||||
<Link href="/" className="flex items-center gap-1.5">
|
||||
<div className="relative w-8 h-8">
|
||||
<Image
|
||||
src={navbar.logo.src}
|
||||
alt={navbar.logo.alt}
|
||||
fill
|
||||
sizes="32px"
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="font-bold font-code text-md">{navbar.logoText}</h2>
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<Link href="/" className="flex items-center gap-1.5">
|
||||
<div className="relative h-8 w-8">
|
||||
<Image
|
||||
src={navbar.logo.src}
|
||||
alt={navbar.logo.alt}
|
||||
fill
|
||||
sizes="32px"
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="font-code dark:text-accent text-primary text-lg font-bold">
|
||||
{navbar.logoText}
|
||||
</h2>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavMenu({ isSheet = false }) {
|
||||
const { navbar } = docuConfig; // Extract navbar from JSON
|
||||
// Desktop NavMenu — horizontal list
|
||||
export function NavMenu() {
|
||||
const { navbar } = docuConfig
|
||||
|
||||
return (
|
||||
<>
|
||||
{navbar?.menu?.map((item) => {
|
||||
const isExternal = item.href.startsWith("http");
|
||||
|
||||
const Comp = (
|
||||
const isExternal = item.href.startsWith("http")
|
||||
return (
|
||||
<Anchor
|
||||
key={`${item.title}-${item.href}`}
|
||||
activeClassName="text-primary dark:text-accent md:font-semibold font-medium"
|
||||
absolute
|
||||
className="flex items-center gap-1 text-foreground/80 hover:text-foreground transition-colors"
|
||||
className="text-foreground/80 hover:text-foreground flex items-center gap-1 transition-colors"
|
||||
href={item.href}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
{item.title}
|
||||
{isExternal && <ArrowUpRight className="w-4 h-4 text-foreground/80" />}
|
||||
{isExternal && <ArrowUpRight className="text-foreground/80 h-4 w-4" />}
|
||||
</Anchor>
|
||||
);
|
||||
return isSheet ? (
|
||||
<SheetClose key={item.title + item.href} asChild>
|
||||
{Comp}
|
||||
</SheetClose>
|
||||
) : (
|
||||
Comp
|
||||
);
|
||||
)
|
||||
})}
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// Mobile Collapsible NavMenu — vertical list items
|
||||
function NavMenuCollapsible({ onItemClick }: { onItemClick: () => void }) {
|
||||
const { navbar } = docuConfig
|
||||
|
||||
return (
|
||||
<>
|
||||
{navbar?.menu?.map((item) => {
|
||||
const isExternal = item.href.startsWith("http")
|
||||
return (
|
||||
<li key={item.title + item.href}>
|
||||
<Anchor
|
||||
activeClassName="text-primary dark:text-accent font-semibold"
|
||||
absolute
|
||||
className="text-foreground/80 hover:text-foreground hover:bg-muted flex w-full items-center justify-between gap-2 rounded-md px-3 py-2.5 text-sm font-medium transition-colors"
|
||||
href={item.href}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
onClick={onItemClick}
|
||||
>
|
||||
{item.title}
|
||||
{isExternal && <ArrowUpRight className="text-foreground/60 h-4 w-4 shrink-0" />}
|
||||
</Anchor>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function Pagination({ pathname }: { pathname: string }) {
|
||||
className:
|
||||
"no-underline w-full flex flex-col pl-3 !py-8 !items-start",
|
||||
})}
|
||||
href={`${res.prev.href}`}
|
||||
href={`/docs${res.prev.href}`}
|
||||
>
|
||||
<span className="flex items-center text-xs">
|
||||
<ChevronLeftIcon className="w-[1rem] h-[1rem] mr-1" />
|
||||
@@ -34,7 +34,7 @@ export default function Pagination({ pathname }: { pathname: string }) {
|
||||
className:
|
||||
"no-underline w-full flex flex-col pr-3 !py-8 !items-end",
|
||||
})}
|
||||
href={`${res.next.href}`}
|
||||
href={`/docs${res.next.href}`}
|
||||
>
|
||||
<span className="flex items-center text-xs">
|
||||
Next
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { SearchTrigger } from "@/components/SearchTrigger";
|
||||
import { SearchModal } from "@/components/SearchModal";
|
||||
import DocSearchComponent from "@/components/DocSearch";
|
||||
import { DialogTrigger } from "@radix-ui/react-dialog";
|
||||
|
||||
interface SearchProps {
|
||||
/**
|
||||
* Specify which search engine to use.
|
||||
* @default 'default'
|
||||
*/
|
||||
type?: "default" | "algolia";
|
||||
}
|
||||
|
||||
export default function Search({ type = "default" }: SearchProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// The useEffect below is ONLY for the 'default' type, which is correct.
|
||||
// DocSearch handles its own keyboard shortcut.
|
||||
useEffect(() => {
|
||||
if (type === 'default') {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
|
||||
event.preventDefault();
|
||||
setIsOpen((open) => !open);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
if (type === "algolia") {
|
||||
// Just render the component without passing any state props
|
||||
return <DocSearchComponent />;
|
||||
}
|
||||
|
||||
// Logic for 'default' search
|
||||
return (
|
||||
<div>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<SearchTrigger />
|
||||
</DialogTrigger>
|
||||
<SearchModal isOpen={isOpen} setIsOpen={setIsOpen} />
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EachRoute } from "@/lib/routes-config";
|
||||
import { EachRoute } from "@/lib/routes";
|
||||
import Anchor from "./anchor";
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -27,7 +27,7 @@ export default function SubLink({
|
||||
parentHref = "",
|
||||
}: SubLinkProps) {
|
||||
const path = usePathname();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(level === 0);
|
||||
|
||||
// Full path including parent's href
|
||||
const fullHref = `${parentHref}${href}`;
|
||||
@@ -54,6 +54,7 @@ export default function SubLink({
|
||||
<Anchor
|
||||
activeClassName={!hasActiveChild ? "dark:text-accent text-primary font-medium" : ""}
|
||||
href={fullHref}
|
||||
data-search-lvl0={level === 0 && hasActiveChild ? "true" : undefined}
|
||||
className={cn(
|
||||
"text-foreground/80 hover:text-foreground transition-colors",
|
||||
hasActiveChild && "font-medium text-foreground"
|
||||
@@ -61,7 +62,7 @@ export default function SubLink({
|
||||
>
|
||||
{title}
|
||||
</Anchor>
|
||||
), [title, fullHref, hasActiveChild]);
|
||||
), [title, fullHref, hasActiveChild, level]);
|
||||
|
||||
const titleOrLink = !noLink ? (
|
||||
isSheet ? (
|
||||
@@ -70,10 +71,13 @@ export default function SubLink({
|
||||
Comp
|
||||
)
|
||||
) : (
|
||||
<h4 className={cn(
|
||||
"font-medium sm:text-sm text-foreground/90 hover:text-foreground transition-colors",
|
||||
hasActiveChild ? "text-foreground" : "text-foreground/80"
|
||||
)}>
|
||||
<h4
|
||||
data-search-lvl0={level === 0 && hasActiveChild ? "true" : undefined}
|
||||
className={cn(
|
||||
"font-medium sm:text-sm text-foreground/90 hover:text-foreground transition-colors",
|
||||
hasActiveChild ? "text-foreground" : "text-foreground/80"
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h4>
|
||||
);
|
||||
@@ -86,11 +90,7 @@ export default function SubLink({
|
||||
<div className={cn("flex flex-col gap-1 w-full")}>
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"w-full pr-5 text-left rounded-md transition-colors",
|
||||
isOpen && "bg-muted/30 pb-2 pt-2", // Background when open
|
||||
hasActiveChild && "bg-primary/5" // Accent tint when child is active
|
||||
)}
|
||||
className="w-full pr-5 text-left cursor-pointer"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={`collapsible-${fullHref.replace(/[^a-zA-Z0-9]/g, '-')}`}
|
||||
>
|
||||
@@ -108,13 +108,13 @@ export default function SubLink({
|
||||
<CollapsibleContent
|
||||
id={`collapsible-${fullHref.replace(/[^a-zA-Z0-9]/g, '-')}`}
|
||||
className={cn(
|
||||
"pl-3 overflow-hidden transition-all duration-200 ease-in-out",
|
||||
"overflow-hidden transition-all duration-200 ease-in-out",
|
||||
isOpen ? "animate-collapsible-down" : "animate-collapsible-up"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start sm:text-sm text-foreground/80 ml-0.5 mt-1.5 gap-3 transition-colors",
|
||||
"flex flex-col items-start sm:text-sm text-foreground/80 ml-0.5 mt-2.5 gap-3 hover:[&_a]:text-foreground transition-colors",
|
||||
level > 0 && "pl-4 border-l border-border ml-1.5"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ScrollToTop } from "./scroll-to-top";
|
||||
import { TocItem } from "@/lib/toc";
|
||||
|
||||
interface TocObserverProps {
|
||||
data: TocItem[];
|
||||
activeId?: string | null;
|
||||
onActiveIdChange?: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export default function TocObserver({
|
||||
data,
|
||||
activeId: externalActiveId,
|
||||
onActiveIdChange
|
||||
}: TocObserverProps) {
|
||||
const [internalActiveId, setInternalActiveId] = useState<string | null>(null);
|
||||
const observer = useRef<IntersectionObserver | null>(null);
|
||||
const [clickedId, setClickedId] = useState<string | null>(null);
|
||||
const itemRefs = useRef<Map<string, HTMLAnchorElement>>(new Map());
|
||||
|
||||
// Use external activeId if provided, otherwise use internal state
|
||||
const activeId = externalActiveId !== undefined ? externalActiveId : internalActiveId;
|
||||
const setActiveId = onActiveIdChange || setInternalActiveId;
|
||||
|
||||
// Handle intersection observer for auto-highlighting
|
||||
useEffect(() => {
|
||||
const handleIntersect = (entries: IntersectionObserverEntry[]) => {
|
||||
const visibleEntries = entries.filter(entry => entry.isIntersecting);
|
||||
|
||||
// Find the most recently scrolled-into-view element
|
||||
const mostVisibleEntry = visibleEntries.reduce((prev, current) => {
|
||||
// Prefer the entry that's more visible or higher on the page
|
||||
const prevRatio = prev?.intersectionRatio || 0;
|
||||
const currentRatio = current.intersectionRatio;
|
||||
|
||||
if (currentRatio > prevRatio) return current;
|
||||
if (currentRatio === prevRatio &&
|
||||
current.boundingClientRect.top < prev.boundingClientRect.top) {
|
||||
return current;
|
||||
}
|
||||
return prev;
|
||||
}, visibleEntries[0]);
|
||||
|
||||
if (mostVisibleEntry && !clickedId) {
|
||||
const newActiveId = mostVisibleEntry.target.id;
|
||||
if (newActiveId !== activeId) {
|
||||
setActiveId(newActiveId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
observer.current = new IntersectionObserver(handleIntersect, {
|
||||
root: null,
|
||||
rootMargin: "-20% 0px -70% 0px", // Adjusted margins for better section detection
|
||||
threshold: [0, 0.1, 0.5, 0.9, 1], // Multiple thresholds for better accuracy
|
||||
});
|
||||
|
||||
const elements = data.map((item) =>
|
||||
document.getElementById(item.href.slice(1))
|
||||
);
|
||||
|
||||
elements.forEach((el) => {
|
||||
if (el && observer.current) {
|
||||
observer.current.observe(el);
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial active ID if none is set
|
||||
if (!activeId && elements[0]) {
|
||||
setActiveId(elements[0].id);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observer.current) {
|
||||
elements.forEach((el) => {
|
||||
if (el) {
|
||||
observer.current!.unobserve(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [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);
|
||||
|
||||
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]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative text-sm text-foreground/70 hover:text-foreground transition-colors">
|
||||
<div className="flex flex-col gap-0">
|
||||
{data.map(({ href, level, text }, index) => {
|
||||
const id = href.slice(1);
|
||||
const isActive = activeId === id;
|
||||
const indent = level > 1 ? (level - 1) * 20 : 0;
|
||||
// Prefix with underscore to indicate intentionally unused
|
||||
const _isParent = hasChildren(id, level);
|
||||
const _isLastInLevel = index === data.length - 1 || data[index + 1].level <= level;
|
||||
|
||||
return (
|
||||
<div key={href} className="relative">
|
||||
{/* Simple L-shaped connector */}
|
||||
{level > 1 && (
|
||||
<div
|
||||
className={clsx("absolute top-0 h-full w-6", {
|
||||
"left-[6px]": indent === 20, // Level 2
|
||||
"left-[22px]": indent === 40, // Level 3
|
||||
"left-[38px]": indent === 60, // Level 4
|
||||
})}
|
||||
>
|
||||
{/* Vertical line */}
|
||||
<div className={clsx(
|
||||
"absolute left-0 top-0 h-full w-px",
|
||||
isActive ? "bg-primary/20 dark:bg-primary/30" : "bg-border/50 dark:bg-border/50"
|
||||
)}>
|
||||
{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 dark:bg-primary/30" : "bg-border/50 dark:bg-border/50"
|
||||
)}>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
className="absolute left-0 top-0 h-full w-full bg-primary dark:bg-accent 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 font-medium": isActive,
|
||||
"text-muted-foreground hover:text-foreground dark:hover:text-foreground/90": !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 dark:bg-primary/90": isActive,
|
||||
"bg-muted-foreground/30 dark:bg-muted-foreground/30 scale-75 group-hover:scale-100 group-hover:bg-primary/50 dark:group-hover:bg-primary/50": !isActive,
|
||||
}
|
||||
)}>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full bg-primary/20 dark:bg-primary/30"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,29 @@
|
||||
import { getDocsTocs } from "@/lib/markdown";
|
||||
import TocObserver from "./toc-observer";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { ListIcon } from "lucide-react";
|
||||
import Sponsor from "./Sponsor";
|
||||
"use client"
|
||||
|
||||
import TocObserver from "./TocObserver"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { ListIcon } from "lucide-react"
|
||||
import Sponsor from "./Sponsor"
|
||||
import { useActiveSection } from "@/hooks"
|
||||
import { TocItem } from "@/lib/toc"
|
||||
|
||||
export default async function Toc({ path }: { path: string }) {
|
||||
const tocs = await getDocsTocs(path);
|
||||
export default function Toc({ tocs }: { tocs: TocItem[] }) {
|
||||
const { activeId, setActiveId } = useActiveSection(tocs)
|
||||
|
||||
return (
|
||||
<div className="lg:flex hidden toc flex-[1.5] min-w-[238px] py-5 sticky top-16 h-[calc(100vh-4rem)]">
|
||||
<div className="flex flex-col h-full w-full px-2 gap-2 mb-auto">
|
||||
<div className="toc flex-3 sticky top-4 hidden h-[calc(100vh-8rem)] min-w-[238px] self-start lg:flex lg:p-8">
|
||||
<div className="mb-auto flex h-full w-full flex-col gap-2 px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListIcon className="w-4 h-4" />
|
||||
<h3 className="font-medium text-sm">On this page</h3>
|
||||
<ListIcon className="h-4 w-4" />
|
||||
<h3 className="text-sm font-medium">On this page</h3>
|
||||
</div>
|
||||
<div className="flex-shrink-0 min-h-0 max-h-[calc(70vh-4rem)]">
|
||||
<div className="max-h-[calc(70vh-2rem)] min-h-0 shrink-0">
|
||||
<ScrollArea className="h-full">
|
||||
<TocObserver data={tocs} />
|
||||
<TocObserver data={tocs} activeId={activeId} onActiveIdChange={setActiveId} />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<Sponsor />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PropsWithChildren } from "react";
|
||||
|
||||
export function Typography({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<div className="prose prose-zinc dark:prose-invert prose-code:font-code dark:prose-code:bg-stone-900/25 prose-code:bg-stone-50 prose-pre:bg-background prose-headings:scroll-m-20 w-[85vw] sm:w-full sm:mx-auto prose-code:text-sm prose-code:leading-6 dark:prose-code:text-white prose-code:text-stone-800 prose-code:p-1 prose-code:rounded-md prose-code:border pt-2 !min-w-full prose-img:rounded-md prose-img:border prose-code:before:content-none prose-code:after:content-none prose-code:px-1.5 prose-code:overflow-x-auto !max-w-[500px] prose-img:my-3 prose-h2:my-4 prose-h2:mt-8">
|
||||
<div className="prose prose-zinc dark:prose-invert prose-code:font-code dark:prose-code:bg-stone-900/25 prose-code:bg-stone-50 prose-pre:bg-background max-lg:prose-headings:scroll-mt-54 prose-headings:scroll-mt-4 w-[85vw] sm:w-full sm:mx-auto prose-code:text-sm prose-code:leading-6 dark:prose-code:text-white prose-code:text-stone-800 prose-code:p-1 prose-code:rounded-md prose-code:border pt-2 min-w-full! prose-img:rounded-md prose-img:border prose-code:before:content-none prose-code:after:content-none prose-code:px-1.5 prose-code:overflow-x-auto max-w-[500px]! prose-img:my-3 prose-h2:my-4 prose-h2:mt-8">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
353
components/ui/icon-cloud.tsx
Normal file
353
components/ui/icon-cloud.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
|
||||
interface Icon {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
scale: number;
|
||||
opacity: number;
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface IconCloudProps {
|
||||
icons?: React.ReactNode[];
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
function easeOutCubic(t: number): number {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
export function IconCloud({ icons, images }: IconCloudProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
// const [iconPositions, setIconPositions] = useState<Icon[]>([]);
|
||||
const iconPositions = useMemo<Icon[]>(() => {
|
||||
const items = icons || images || [];
|
||||
const newIcons: Icon[] = [];
|
||||
const numIcons = items.length || 20;
|
||||
|
||||
// Fibonacci sphere parameters
|
||||
const offset = 2 / numIcons;
|
||||
const increment = Math.PI * (3 - Math.sqrt(5));
|
||||
|
||||
for (let i = 0; i < numIcons; i++) {
|
||||
const y = i * offset - 1 + offset / 2;
|
||||
const r = Math.sqrt(1 - y * y);
|
||||
const phi = i * increment;
|
||||
|
||||
const x = Math.cos(phi) * r;
|
||||
const z = Math.sin(phi) * r;
|
||||
|
||||
newIcons.push({
|
||||
x: x * 100,
|
||||
y: y * 100,
|
||||
z: z * 100,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
id: i,
|
||||
});
|
||||
}
|
||||
return newIcons;
|
||||
}, [icons, images]);
|
||||
const [rotation] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 });
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
const [targetRotation, setTargetRotation] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
distance: number;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
} | null>(null);
|
||||
const animationFrameRef = useRef<number>(undefined);
|
||||
const rotationRef = useRef(rotation);
|
||||
const iconCanvasesRef = useRef<HTMLCanvasElement[]>([]);
|
||||
const imagesLoadedRef = useRef<boolean[]>([]);
|
||||
|
||||
// Create icon canvases once when icons/images change
|
||||
useEffect(() => {
|
||||
if (!icons && !images) return;
|
||||
|
||||
const items = icons || images || [];
|
||||
imagesLoadedRef.current = new Array(items.length).fill(false);
|
||||
|
||||
const newIconCanvases = items.map((item, index) => {
|
||||
const offscreen = document.createElement("canvas");
|
||||
offscreen.width = 40;
|
||||
offscreen.height = 40;
|
||||
const offCtx = offscreen.getContext("2d");
|
||||
|
||||
if (offCtx) {
|
||||
if (images) {
|
||||
// Handle image URLs directly
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.src = items[index] as string;
|
||||
img.onload = () => {
|
||||
offCtx.clearRect(0, 0, offscreen.width, offscreen.height);
|
||||
|
||||
// Create circular clipping path
|
||||
offCtx.beginPath();
|
||||
offCtx.arc(20, 20, 20, 0, Math.PI * 2);
|
||||
offCtx.closePath();
|
||||
offCtx.clip();
|
||||
|
||||
// Draw the image
|
||||
offCtx.drawImage(img, 0, 0, 40, 40);
|
||||
|
||||
imagesLoadedRef.current[index] = true;
|
||||
};
|
||||
} else {
|
||||
// Handle SVG icons
|
||||
offCtx.scale(0.4, 0.4);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const svgString = renderToString(item as React.ReactElement<any>);
|
||||
const img = new Image();
|
||||
img.src = "data:image/svg+xml;base64," + btoa(svgString);
|
||||
img.onload = () => {
|
||||
offCtx.clearRect(0, 0, offscreen.width, offscreen.height);
|
||||
offCtx.drawImage(img, 0, 0);
|
||||
imagesLoadedRef.current[index] = true;
|
||||
};
|
||||
}
|
||||
}
|
||||
return offscreen;
|
||||
});
|
||||
|
||||
iconCanvasesRef.current = newIconCanvases;
|
||||
}, [icons, images]);
|
||||
|
||||
// Generate initial icon positions on a sphere
|
||||
// useEffect(() => {
|
||||
// const items = icons || images || [];
|
||||
// const newIcons: Icon[] = [];
|
||||
// const numIcons = items.length || 20;
|
||||
|
||||
// // Fibonacci sphere parameters
|
||||
// const offset = 2 / numIcons;
|
||||
// const increment = Math.PI * (3 - Math.sqrt(5));
|
||||
|
||||
// for (let i = 0; i < numIcons; i++) {
|
||||
// const y = i * offset - 1 + offset / 2;
|
||||
// const r = Math.sqrt(1 - y * y);
|
||||
// const phi = i * increment;
|
||||
|
||||
// const x = Math.cos(phi) * r;
|
||||
// const z = Math.sin(phi) * r;
|
||||
|
||||
// newIcons.push({
|
||||
// x: x * 100,
|
||||
// y: y * 100,
|
||||
// z: z * 100,
|
||||
// scale: 1,
|
||||
// opacity: 1,
|
||||
// id: i,
|
||||
// });
|
||||
// }
|
||||
// setIconPositions(newIcons);
|
||||
// }, [icons, images]);
|
||||
|
||||
// Handle mouse events
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect || !canvasRef.current) return;
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const ctx = canvasRef.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
iconPositions.forEach((icon) => {
|
||||
const cosX = Math.cos(rotationRef.current.x);
|
||||
const sinX = Math.sin(rotationRef.current.x);
|
||||
const cosY = Math.cos(rotationRef.current.y);
|
||||
const sinY = Math.sin(rotationRef.current.y);
|
||||
|
||||
const rotatedX = icon.x * cosY - icon.z * sinY;
|
||||
const rotatedZ = icon.x * sinY + icon.z * cosY;
|
||||
const rotatedY = icon.y * cosX + rotatedZ * sinX;
|
||||
|
||||
const screenX = canvasRef.current!.width / 2 + rotatedX;
|
||||
const screenY = canvasRef.current!.height / 2 + rotatedY;
|
||||
|
||||
const scale = (rotatedZ + 200) / 300;
|
||||
const radius = 20 * scale;
|
||||
const dx = x - screenX;
|
||||
const dy = y - screenY;
|
||||
|
||||
if (dx * dx + dy * dy < radius * radius) {
|
||||
const targetX = -Math.atan2(
|
||||
icon.y,
|
||||
Math.sqrt(icon.x * icon.x + icon.z * icon.z),
|
||||
);
|
||||
const targetY = Math.atan2(icon.x, icon.z);
|
||||
|
||||
const currentX = rotationRef.current.x;
|
||||
const currentY = rotationRef.current.y;
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2),
|
||||
);
|
||||
|
||||
const duration = Math.min(2000, Math.max(800, distance * 1000));
|
||||
|
||||
setTargetRotation({
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
startX: currentX,
|
||||
startY: currentY,
|
||||
distance,
|
||||
startTime: performance.now(),
|
||||
duration,
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
setIsDragging(true);
|
||||
setLastMousePos({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
setMousePos({ x, y });
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
const deltaX = e.clientX - lastMousePos.x;
|
||||
const deltaY = e.clientY - lastMousePos.y;
|
||||
|
||||
rotationRef.current = {
|
||||
x: rotationRef.current.x + deltaY * 0.002,
|
||||
y: rotationRef.current.y + deltaX * 0.002,
|
||||
};
|
||||
|
||||
setLastMousePos({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
// Animation and rendering
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!canvas || !ctx) return;
|
||||
|
||||
const animate = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
const maxDistance = Math.sqrt(centerX * centerX + centerY * centerY);
|
||||
const dx = mousePos.x - centerX;
|
||||
const dy = mousePos.y - centerY;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const speed = 0.003 + (distance / maxDistance) * 0.01;
|
||||
|
||||
if (targetRotation) {
|
||||
const elapsed = performance.now() - targetRotation.startTime;
|
||||
const progress = Math.min(1, elapsed / targetRotation.duration);
|
||||
const easedProgress = easeOutCubic(progress);
|
||||
|
||||
rotationRef.current = {
|
||||
x:
|
||||
targetRotation.startX +
|
||||
(targetRotation.x - targetRotation.startX) * easedProgress,
|
||||
y:
|
||||
targetRotation.startY +
|
||||
(targetRotation.y - targetRotation.startY) * easedProgress,
|
||||
};
|
||||
|
||||
if (progress >= 1) {
|
||||
setTargetRotation(null);
|
||||
}
|
||||
} else if (!isDragging) {
|
||||
rotationRef.current = {
|
||||
x: rotationRef.current.x + (dy / canvas.height) * speed,
|
||||
y: rotationRef.current.y + (dx / canvas.width) * speed,
|
||||
};
|
||||
}
|
||||
|
||||
iconPositions.forEach((icon, index) => {
|
||||
const cosX = Math.cos(rotationRef.current.x);
|
||||
const sinX = Math.sin(rotationRef.current.x);
|
||||
const cosY = Math.cos(rotationRef.current.y);
|
||||
const sinY = Math.sin(rotationRef.current.y);
|
||||
|
||||
const rotatedX = icon.x * cosY - icon.z * sinY;
|
||||
const rotatedZ = icon.x * sinY + icon.z * cosY;
|
||||
const rotatedY = icon.y * cosX + rotatedZ * sinX;
|
||||
|
||||
const scale = (rotatedZ + 200) / 300;
|
||||
const opacity = Math.max(0.2, Math.min(1, (rotatedZ + 150) / 200));
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(
|
||||
canvas.width / 2 + rotatedX,
|
||||
canvas.height / 2 + rotatedY,
|
||||
);
|
||||
ctx.scale(scale, scale);
|
||||
ctx.globalAlpha = opacity;
|
||||
|
||||
if (icons || images) {
|
||||
// Only try to render icons/images if they exist
|
||||
if (
|
||||
iconCanvasesRef.current[index] &&
|
||||
imagesLoadedRef.current[index]
|
||||
) {
|
||||
ctx.drawImage(iconCanvasesRef.current[index], -20, -20, 40, 40);
|
||||
}
|
||||
} else {
|
||||
// Show numbered circles if no icons/images are provided
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 20, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#4444ff";
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "white";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.font = "16px Arial";
|
||||
ctx.fillText(`${icon.id + 1}`, 0, 0);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
});
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [icons, images, iconPositions, isDragging, mousePos, targetRotation]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={400}
|
||||
height={400}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
className="rounded-full"
|
||||
aria-label="Interactive 3D Icon Cloud"
|
||||
role="img"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -33,9 +33,9 @@ const ScrollBar = React.forwardRef<
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
"h-full w-2.5 border-l border-l-transparent p-px",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
"h-2.5 flex-col border-t border-t-transparent p-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import { PanelRightClose } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -51,7 +51,7 @@ const sheetVariants = cva(
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
VariantProps<typeof sheetVariants> { }
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
@@ -65,8 +65,8 @@ const SheetContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-7 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<SheetPrimitive.Close className="absolute top-7 right-4 z-50 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<PanelRightClose className="w-6 h-6 shrink-0 text-muted-foreground" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
|
||||
@@ -6,10 +6,10 @@ const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<div className="relative w-full overflow-auto border border-border rounded-lg">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
className={cn("w-full caption-bottom text-sm !my-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
@@ -20,7 +20,7 @@ const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b bg-muted", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap px-1.5 py-[0.58rem] text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:border-primary border-b-2 border-transparent data-[state=active]:text-foreground font-code",
|
||||
"inline-flex items-center justify-center whitespace-nowrap px-1.5 py-[0.58rem] text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:border-primary border-b-2 border-transparent data-[state=active]:text-foreground font-code cursor-pointer",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-1 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"inline-flex items-center justify-center gap-1 rounded-md text-xs font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-3 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -18,7 +18,7 @@ const toggleVariants = cva(
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
xs: "h-7 px-1 min-w-7",
|
||||
xs: "h-6 px-1.5 min-w-6",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
@@ -32,7 +32,7 @@ const toggleVariants = cva(
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"pages": [
|
||||
"licensing"
|
||||
]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
title: Changelog
|
||||
description: Latest updates and changes to WooNooW
|
||||
date: 2024-01-31
|
||||
---
|
||||
|
||||
## Initial Release
|
||||
|
||||
<Release version="1.0.0" date="2024-01-31" title="Initial Public Release">
|
||||
<Changes type="added">
|
||||
- Core plugin functionality for WooCommerce enhancement.
|
||||
- Licensing module with OAuth activation flow.
|
||||
- Subscription management and payment gateway integration.
|
||||
- Extensive hook system for developers.
|
||||
</Changes>
|
||||
</Release>
|
||||
@@ -1,87 +0,0 @@
|
||||
---
|
||||
title: Licensing API
|
||||
description: Endpoints for activating, validating, and managing licenses
|
||||
date: 2024-01-31
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Licensing API allows external applications to interact with the WooNooW licensing system.
|
||||
|
||||
**Base URL**: `https://your-domain.com/wp-json/woonoow/v1`
|
||||
|
||||
---
|
||||
|
||||
## Public Endpoints
|
||||
|
||||
### Activate License
|
||||
|
||||
Activates a license key for a specific domain.
|
||||
|
||||
```http
|
||||
POST /licenses/activate
|
||||
```
|
||||
|
||||
#### Activation Parameters
|
||||
|
||||
| Body Params | Type | Required | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `license_key` | `string` | **Yes** | The license key to activate |
|
||||
| `domain` | `string` | **Yes** | The domain where the software is installed |
|
||||
| `activation_mode` | `string` | No | Set to `oauth` to trigger OAuth flow |
|
||||
|
||||
#### Responses
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"activation_id": 123,
|
||||
"license_key": "XXXX-YYYY-ZZZZ-WWWW",
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
If OAuth is required:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"oauth_required": true,
|
||||
"oauth_redirect": "https://vendor.com/my-account/license-connect/...",
|
||||
"state": "abc12345"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Validate License
|
||||
|
||||
Checks if a license key is valid and active for the current domain.
|
||||
|
||||
```http
|
||||
POST /licenses/validate
|
||||
```
|
||||
|
||||
#### Validation Parameters
|
||||
|
||||
| Body Params | Type | Required | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `license_key` | `string` | **Yes** | The license key to validate |
|
||||
| `domain` | `string` | **Yes** | The domain to check against |
|
||||
|
||||
---
|
||||
|
||||
### Deactivate License
|
||||
|
||||
Deactivates a license for the current domain.
|
||||
|
||||
```http
|
||||
POST /licenses/deactivate
|
||||
```
|
||||
|
||||
#### Deactivation Parameters
|
||||
|
||||
| Body Params | Type | Required | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `license_key` | `string` | **Yes** | The license key to deactivate |
|
||||
| `domain` | `string` | **Yes** | The domain to remove |
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
title: Developer Guide
|
||||
description: Extend and customize WooNooW.
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
WooNooW is built with extensibility in mind.
|
||||
|
||||
### [Addons System](/docs/developer/addons/module-integration)
|
||||
Learn how to create custom modules that plug into the WooNooW ecosystem.
|
||||
|
||||
### [React Integration](/docs/developer/addons/react-integration)
|
||||
Understand how we bridge PHP and React to create seamless admin interfaces.
|
||||
|
||||
### [API Reference](/docs/developer/api/licensing)
|
||||
Detailed documentation of our REST API endpoints.
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
title : Development
|
||||
description : for Development server and production
|
||||
date : 10-12-2024
|
||||
---
|
||||
|
||||
## Heading 2
|
||||
|
||||
this is regular text written in markdown format with `inline code`, **bold**, and *italic*
|
||||
|
||||
### Heading 3
|
||||
|
||||
example of ordered list format :
|
||||
|
||||
- list one
|
||||
- sub list
|
||||
- list two
|
||||
- list three
|
||||
|
||||
#### Heading 4
|
||||
|
||||
Below is an example of how to write a code block :
|
||||
|
||||
````plaintext
|
||||
```javascript:main.js showLineNumbers {3-4}
|
||||
function isRocketAboutToCrash() {
|
||||
// Check if the rocket is stable
|
||||
if (!isStable()) {
|
||||
NoCrash(); // Prevent the crash
|
||||
}
|
||||
}
|
||||
```
|
||||
````
|
||||
|
||||
example note :
|
||||
```plaintext
|
||||
<Note type="note" title="Note">
|
||||
This is a general note to convey information to the user.
|
||||
</Note>
|
||||
```
|
||||
|
||||
displaying an image in markdown format :
|
||||
|
||||
```plaintext
|
||||

|
||||
```
|
||||
|
||||
render as :
|
||||

|
||||
|
||||
For a complete guide on using markdown content in DocuBook, please refer to the [Components](https://docubook.pro/docs/components) page.
|
||||
|
||||
<Note type="warning" title="Warning">
|
||||
every page that is indexed in a folder will have an `index.mdx` file with metadata :
|
||||
```plaintext
|
||||
---
|
||||
title : Introduction
|
||||
description : overview or synopsis of a project
|
||||
date : 10-12-2024
|
||||
image : example-img.png
|
||||
---
|
||||
```
|
||||
</Note>
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"pages": [
|
||||
"introduction",
|
||||
"quick-start-guide",
|
||||
"development"
|
||||
]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"pages": [
|
||||
"index",
|
||||
"notifications",
|
||||
"subscriptions",
|
||||
"frontend",
|
||||
"newsletter"
|
||||
]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"pages": [
|
||||
"getting-started",
|
||||
"licensing",
|
||||
"hooks",
|
||||
"api-reference",
|
||||
"changelog"
|
||||
]
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
---
|
||||
title: Frequently Asked Questions
|
||||
description: Quick answers to common questions about WooNooW
|
||||
date: 2024-01-31
|
||||
---
|
||||
|
||||
## General
|
||||
|
||||
### What is WooNooW?
|
||||
|
||||
WooNooW is a WooCommerce plugin that transforms your store into a modern Single Page Application (SPA). It provides instant page loads, a beautiful UI, and seamless shopping experience.
|
||||
|
||||
### Do I need WooCommerce?
|
||||
|
||||
Yes. WooNooW is an enhancement layer for WooCommerce. You need WooCommerce installed and activated.
|
||||
|
||||
### Will WooNooW affect my existing products?
|
||||
|
||||
No. WooNooW reads from WooCommerce. Your products, orders, and settings remain untouched.
|
||||
|
||||
---
|
||||
|
||||
## SPA Mode
|
||||
|
||||
### What's the difference between Full and Disabled mode?
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| **Full** | All WooCommerce pages redirect to SPA. Modern, fast experience. |
|
||||
| **Disabled** | WooCommerce pages use native templates. WooNooW admin still works. |
|
||||
|
||||
### Can I switch modes anytime?
|
||||
|
||||
Yes. Go to **WooNooW → Appearance → General** and change the SPA Mode. Changes take effect immediately.
|
||||
|
||||
### Which mode should I use?
|
||||
|
||||
- **Full**: For the best customer experience with instant loads
|
||||
- **Disabled**: If you have theme customizations you want to keep
|
||||
|
||||
---
|
||||
|
||||
## Compatibility
|
||||
|
||||
### Does WooNooW work with my theme?
|
||||
|
||||
WooNooW's SPA is independent of your WordPress theme. In Full mode, the SPA uses its own styling. Your theme affects the rest of your site normally.
|
||||
|
||||
### Does WooNooW work with page builders?
|
||||
|
||||
The SPA pages are self-contained. Page builders work on other pages of your site.
|
||||
|
||||
### Which payment gateways are supported?
|
||||
|
||||
WooNooW supports all WooCommerce-compatible payment gateways:
|
||||
- PayPal
|
||||
- Stripe
|
||||
- Bank Transfer (BACS)
|
||||
- Cash on Delivery
|
||||
- And more...
|
||||
|
||||
---
|
||||
|
||||
## SEO
|
||||
|
||||
### Is WooNooW SEO-friendly?
|
||||
|
||||
Yes. WooNooW uses:
|
||||
- Clean URLs (`/store/product/product-name`)
|
||||
- Dynamic meta tags for social sharing
|
||||
- Proper redirects (302) from WooCommerce URLs
|
||||
|
||||
### What about my existing SEO?
|
||||
|
||||
WooCommerce URLs remain the indexed source. WooNooW redirects users to the SPA but preserves SEO value.
|
||||
|
||||
### Will my product pages be indexed?
|
||||
|
||||
Yes. Search engines index the WooCommerce URLs. When users click from search results, they're redirected to the fast SPA experience.
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### Is WooNooW faster than regular WooCommerce?
|
||||
|
||||
Yes, for navigation. After the initial load, page transitions are instant because the SPA doesn't reload the entire page.
|
||||
|
||||
### Will WooNooW slow down my site?
|
||||
|
||||
The initial load is similar to regular WooCommerce. Subsequent navigation is much faster.
|
||||
|
||||
### Does WooNooW work with caching?
|
||||
|
||||
Yes. Use page caching and object caching for best results.
|
||||
|
||||
---
|
||||
|
||||
## Customization
|
||||
|
||||
### Can I customize colors and fonts?
|
||||
|
||||
Yes. Go to **WooNooW → Appearance** to customize:
|
||||
- Primary, secondary, and accent colors
|
||||
- Body and heading fonts
|
||||
- Logo and layout options
|
||||
|
||||
### Can I add custom CSS?
|
||||
|
||||
Currently, use your theme's Additional CSS feature. A custom CSS field may be added in future versions.
|
||||
|
||||
### Can I modify the SPA templates?
|
||||
|
||||
The SPA is built with React. Advanced customizations require development knowledge.
|
||||
@@ -1,175 +0,0 @@
|
||||
---
|
||||
title: Troubleshooting
|
||||
description: Common issues and their solutions
|
||||
date: 2024-01-31
|
||||
---
|
||||
|
||||
## Blank Pages
|
||||
|
||||
### Symptom
|
||||
WooCommerce pages (shop, cart, checkout) show blank content.
|
||||
|
||||
### Solutions
|
||||
|
||||
**1. Check SPA Mode Setting**
|
||||
- Go to **WooNooW → Appearance → General**
|
||||
- Ensure **SPA Mode** is set to "Full"
|
||||
- If you want native WooCommerce, set to "Disabled"
|
||||
|
||||
**2. Flush Permalinks**
|
||||
- Go to **Settings → Permalinks**
|
||||
- Click **Save Changes** (no changes needed)
|
||||
- This refreshes rewrite rules
|
||||
|
||||
**3. Clear Cache**
|
||||
If using a caching plugin:
|
||||
- Clear page cache
|
||||
- Clear object cache
|
||||
- Purge CDN cache (if applicable)
|
||||
|
||||
---
|
||||
|
||||
## 404 Errors on SPA Routes
|
||||
|
||||
### Symptom
|
||||
Visiting `/store/shop` or `/store/product/...` shows a 404 error.
|
||||
|
||||
### Solutions
|
||||
|
||||
**1. Flush Permalinks**
|
||||
- Go to **Settings → Permalinks**
|
||||
- Click **Save Changes**
|
||||
|
||||
**2. Check Store Page Exists**
|
||||
- Go to **Pages**
|
||||
- Verify "Store" page exists and is published
|
||||
- The page should contain `[woonoow_spa]` shortcode
|
||||
|
||||
**3. Check SPA Page Setting**
|
||||
- Go to **WooNooW → Appearance → General**
|
||||
- Ensure **SPA Page** is set to the Store page
|
||||
|
||||
---
|
||||
|
||||
## Product Images Not Loading
|
||||
|
||||
### Symptom
|
||||
Products show placeholder images instead of actual images.
|
||||
|
||||
### Solutions
|
||||
|
||||
**1. Regenerate Thumbnails**
|
||||
- Install "Regenerate Thumbnails" plugin
|
||||
- Run regeneration for all images
|
||||
|
||||
**2. Check Image URLs**
|
||||
- Ensure images have valid URLs
|
||||
- Check for mixed content (HTTP vs HTTPS)
|
||||
|
||||
---
|
||||
|
||||
## Slow Performance
|
||||
|
||||
### Symptom
|
||||
SPA feels slow or laggy.
|
||||
|
||||
### Solutions
|
||||
|
||||
**1. Enable Caching**
|
||||
- Install a caching plugin (WP Super Cache, W3 Total Cache)
|
||||
- Enable object caching (Redis/Memcached)
|
||||
|
||||
**2. Optimize Images**
|
||||
- Use WebP format
|
||||
- Compress images before upload
|
||||
- Use lazy loading
|
||||
|
||||
**3. Check Server Resources**
|
||||
- Upgrade hosting if on shared hosting
|
||||
- Consider VPS or managed WordPress hosting
|
||||
|
||||
---
|
||||
|
||||
## Checkout Not Working
|
||||
|
||||
### Symptom
|
||||
Checkout page won't load or payment fails.
|
||||
|
||||
### Solutions
|
||||
|
||||
**1. Check Payment Gateway**
|
||||
- Go to **WooCommerce → Settings → Payments**
|
||||
- Verify payment method is enabled
|
||||
- Check API credentials
|
||||
|
||||
**2. Check SSL Certificate**
|
||||
- Checkout requires HTTPS
|
||||
- Verify SSL is properly installed
|
||||
|
||||
**3. Check for JavaScript Errors**
|
||||
- Open browser Developer Tools (F12)
|
||||
- Check Console for errors
|
||||
- Look for blocked scripts
|
||||
|
||||
---
|
||||
|
||||
## Emails Not Sending
|
||||
|
||||
### Symptom
|
||||
Order confirmation emails not being received.
|
||||
|
||||
### Solutions
|
||||
|
||||
**1. Check Email Settings**
|
||||
- Go to **WooNooW → Settings → Notifications**
|
||||
- Verify email types are enabled
|
||||
|
||||
**2. Check WordPress Email**
|
||||
- Test with a plugin like "Check & Log Email"
|
||||
- Consider using SMTP plugin (WP Mail SMTP)
|
||||
|
||||
**3. Check Spam Folder**
|
||||
- Emails may be in recipient's spam folder
|
||||
- Add sender to whitelist
|
||||
|
||||
---
|
||||
|
||||
## Plugin Conflicts
|
||||
|
||||
### Symptom
|
||||
WooNooW doesn't work after installing another plugin.
|
||||
|
||||
### Steps to Diagnose
|
||||
|
||||
1. **Deactivate other plugins** one by one
|
||||
2. **Switch to default theme** (Twenty Twenty-Three)
|
||||
3. **Check error logs** in `wp-content/debug.log`
|
||||
|
||||
### Common Conflicting Plugins
|
||||
|
||||
- Other WooCommerce template overrides
|
||||
- Page builder plugins (sometimes)
|
||||
- Heavy caching plugins (misconfigured)
|
||||
|
||||
---
|
||||
|
||||
## Getting More Help
|
||||
|
||||
If you can't resolve the issue:
|
||||
|
||||
1. **Collect Information**
|
||||
- WordPress version
|
||||
- WooCommerce version
|
||||
- WooNooW version
|
||||
- PHP version
|
||||
- Error messages (from debug.log)
|
||||
|
||||
2. **Enable Debug Mode**
|
||||
Add to `wp-config.php`:
|
||||
```php
|
||||
define('WP_DEBUG', true);
|
||||
define('WP_DEBUG_LOG', true);
|
||||
```
|
||||
|
||||
3. **Contact Support**
|
||||
Provide the collected information for faster resolution.
|
||||
20
docs/developer/overview.mdx
Normal file
20
docs/developer/overview.mdx
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
title: Developer Guide
|
||||
description: Extend and customize WooNooW.
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
WooNooW is built with extensibility in mind.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Addons System" icon="Package" href="/docs/developer/addons/module-integration">
|
||||
Learn how to create custom modules that plug into the WooNooW ecosystem.
|
||||
</Card>
|
||||
<Card title="React Integration" icon="Plug" href="/docs/developer/addons/react-integration">
|
||||
Understand how we bridge PHP and React to create seamless admin interfaces.
|
||||
</Card>
|
||||
<Card title="API Reference" icon="Zap" href="/docs/developer/api/licensing">
|
||||
Detailed documentation of our REST API endpoints.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
9
docs/features/index.mdx
Normal file
9
docs/features/index.mdx
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title : Features
|
||||
description : Showcases the features of Woonoow
|
||||
date : 10-12-2024
|
||||
---
|
||||
|
||||
This page showcases the features of Woonoow.
|
||||
|
||||
<Outlet path="/features" />
|
||||
@@ -69,8 +69,6 @@ Triggered when the system determines a push notification should be sent.
|
||||
* `$recipient_type` (string): Type of recipient.
|
||||
* `$email` (string): The email address it was sent to.
|
||||
|
||||
### woonoow_send_push_notification
|
||||
|
||||
Triggered to send a push notification.
|
||||
|
||||
**Parameters:**
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user