refactor: Migrate documentation content, rebuild UI components, and update core architecture.

This commit is contained in:
gitfromwildan
2026-03-10 01:38:58 +07:00
parent aac81dff8a
commit ab755844a3
132 changed files with 3947 additions and 12862 deletions

7
.gitignore vendored
View File

@@ -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

View File

@@ -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"]

View File

@@ -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>
);
)
}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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" />

1462
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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>
);
}
)
}

View File

@@ -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
View 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
View 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
View 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
View 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>
);
}

View File

@@ -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;

View File

@@ -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
View 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>
)
}

View 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;
}

View File

@@ -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>
);
}
)
}

View File

@@ -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>
);
}
)
}

View File

@@ -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}

View File

@@ -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
View 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>
)
}

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
)
}

View 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>
)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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}
>

View File

@@ -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'}
`} />
)}

View File

@@ -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);
};

View File

@@ -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>
);
}

View File

@@ -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} />,
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
)
})}
</>
)
}

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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"
)}
>

View File

@@ -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>
);
}

View File

@@ -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>
);
)
}

View File

@@ -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>
);

View 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"
/>
);
}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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"

View File

@@ -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}

View File

@@ -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}

View File

@@ -1,5 +0,0 @@
{
"pages": [
"licensing"
]
}

View File

@@ -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>

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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
![Alt text for the image](/images/example-img.png)
```
render as :
![Alt text for the image](/images/example-img.png)
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>

View File

@@ -1,7 +0,0 @@
{
"pages": [
"introduction",
"quick-start-guide",
"development"
]
}

View File

@@ -1,9 +0,0 @@
{
"pages": [
"index",
"notifications",
"subscriptions",
"frontend",
"newsletter"
]
}

View File

@@ -1,9 +0,0 @@
{
"pages": [
"getting-started",
"licensing",
"hooks",
"api-reference",
"changelog"
]
}

View File

@@ -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.

View File

@@ -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.

View 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
View 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" />

View File

@@ -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