Compare commits

..

16 Commits

Author SHA1 Message Date
gitfromwildan
bf2ef37f49 feat : dockerignore 2026-03-10 20:22:21 +07:00
gitfromwildan
9a2b5f57ed update dockerfile from webhook 2026-03-10 17:06:38 +07:00
gitfromwildan
9a0886817a add algolia env to Dockerfile 2026-03-10 16:52:26 +07:00
gitfromwildan
88add102be refactor: Restructure developer documentation by flattening the software updates section and moving the store owner guide. 2026-03-10 14:40:34 +07:00
gitfromwildan
4c4b604814 change og-image from webhook 2026-03-10 02:09:00 +07:00
gitfromwildan
f714a0a942 update runner from webhook 2026-03-10 02:06:44 +07:00
gitfromwildan
c7354aee85 update build from webhooks 2026-03-10 01:58:16 +07:00
gitfromwildan
ab755844a3 refactor: Migrate documentation content, rebuild UI components, and update core architecture. 2026-03-10 01:38:58 +07:00
Dwindi Ramadhana
aac81dff8a modify sublink.tsx, markdown.ts and favicon.icon 2026-02-28 00:16:43 +07:00
Dwindi Ramadhana
8789de2e2c fix: resolve 404 for software-updates/store-owner and fix duplicate /docs/docs routing bug in search results 2026-02-28 00:13:38 +07:00
Dwindi Ramadhana
b7fbcef6b1 docs: add newly created pages to sidebar navigation menu (Subscriptions, Core Features, Software Updates) 2026-02-28 00:01:42 +07:00
Dwindi Ramadhana
a1055d3f22 docs: add missing documentation for new features (checkout auto-registration, visual builder sections, appearance toggles, software & subscription guides) 2026-02-27 23:48:01 +07:00
Dwindi Ramadhana
f58de663a3 docs: add screenshots to modules, licensing, wishlist, and footer pages 2026-02-05 23:23:12 +07:00
Dwindi Ramadhana
85efc218c6 fix(deploy): upgrade node version to 20-alpine for next.js 16 support 2026-02-05 23:16:39 +07:00
Dwindi Ramadhana
d3e89f1a42 fix(deploy): unignore and add package-lock.json for docker build 2026-02-05 23:07:59 +07:00
Dwindi Ramadhana
aa33e15604 chore: add Dockerfile and standalone config for Coolify deployment 2026-02-05 22:54:20 +07:00
140 changed files with 4965 additions and 2001 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
.next
.git
.gitignore
Dockerfile
README.md
.env*

7
.gitignore vendored
View File

@@ -32,6 +32,7 @@ out/
.env.development .env.development
.env.test .env.test
.env.production .env.production
.crawler
# Debug logs # Debug logs
npm-debug.log* npm-debug.log*
@@ -61,12 +62,6 @@ Thumbs.db
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# Package managers
package-lock.json
yarn.lock
bun.lock
pnpm-lock.yaml
# Build outputs # Build outputs
.next .next
out out

77
Dockerfile Normal file
View File

@@ -0,0 +1,77 @@
# --------------------------------
# Base
# --------------------------------
FROM node:20-alpine AS base
WORKDIR /app
RUN apk add --no-cache libc6-compat
# --------------------------------
# Dependencies
# --------------------------------
FROM base AS deps
# Install Bun
RUN npm install -g bun
# Copy package files
COPY package.json bun.lockb* ./
# Install dependencies
RUN bun install --frozen-lockfile
# --------------------------------
# Builder
# --------------------------------
FROM base AS builder
WORKDIR /app
RUN npm install -g bun
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# --- Algolia build args (Coolify injects these) ---
ARG NEXT_PUBLIC_ALGOLIA_DOCSEARCH_APP_ID
ARG NEXT_PUBLIC_ALGOLIA_DOCSEARCH_API_KEY
ARG NEXT_PUBLIC_ALGOLIA_DOCSEARCH_INDEX_NAME
ENV NEXT_PUBLIC_ALGOLIA_DOCSEARCH_APP_ID=$NEXT_PUBLIC_ALGOLIA_DOCSEARCH_APP_ID
ENV NEXT_PUBLIC_ALGOLIA_DOCSEARCH_API_KEY=$NEXT_PUBLIC_ALGOLIA_DOCSEARCH_API_KEY
ENV NEXT_PUBLIC_ALGOLIA_DOCSEARCH_INDEX_NAME=$NEXT_PUBLIC_ALGOLIA_DOCSEARCH_INDEX_NAME
# Build Next.js
RUN bun run build
# --------------------------------
# Runner
# --------------------------------
FROM node:20-alpine AS runner
WORKDIR /app
RUN apk add --no-cache curl libc6-compat
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create non-root user
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Copy standalone build
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
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 { notFound } from "next/navigation"
import { getDocsForSlug, getDocsTocs } from "@/lib/markdown"; import { getDocsForSlug, getDocsTocs } from "@/lib/markdown"
import DocsBreadcrumb from "@/components/docs-breadcrumb"; import DocsBreadcrumb from "@/components/DocsBreadcrumb"
import Pagination from "@/components/pagination"; import Pagination from "@/components/pagination"
import Toc from "@/components/toc"; import Toc from "@/components/toc"
import { Typography } from "@/components/typography"; import { Typography } from "@/components/typography"
import EditThisPage from "@/components/edit-on-github"; import EditThisPage from "@/components/EditWithGithub"
import { formatDate2 } from "@/lib/utils"; import { formatDate2 } from "@/lib/utils"
import docuConfig from "@/docu.json"; import docuConfig from "@/docu.json"
import MobToc from "@/components/mob-toc"; import MobToc from "@/components/DocsSidebar"
const { meta } = docuConfig; const { meta } = docuConfig
type PageProps = { type PageProps = {
params: Promise<{ params: Promise<{
slug: string[]; slug: string[]
}>; }>
}; }
// Function to generate metadata dynamically // Function to generate metadata dynamically
export async function generateMetadata(props: PageProps) { export async function generateMetadata(props: PageProps) {
const params = await props.params; const params = await props.params
const { const { slug = [] } = params
slug = []
} = params;
const pathName = slug.join("/"); const pathName = slug.join("/")
const res = await getDocsForSlug(pathName); const res = await getDocsForSlug(pathName)
if (!res) { if (!res) {
return { return {
title: "Page Not Found", title: "Page Not Found",
description: "The requested page was 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 // Absolute URL for og:image
const ogImage = image const ogImage = image ? `${meta.baseURL}/images/${image}` : `${meta.baseURL}/images/og-image.png`
? `${meta.baseURL}/images/${image}`
: `${meta.baseURL}/images/og-image.png`;
return { return {
title: `${title}`, title: `${title}`,
@@ -65,45 +61,40 @@ export async function generateMetadata(props: PageProps) {
description, description,
images: [ogImage], images: [ogImage],
}, },
}; }
} }
export default async function DocsPage(props: PageProps) { export default async function DocsPage(props: PageProps) {
const params = await props.params; const params = await props.params
const { const { slug = [] } = params
slug = []
} = params;
const pathName = slug.join("/"); const pathName = slug.join("/")
const res = await getDocsForSlug(pathName); const res = await getDocsForSlug(pathName)
if (!res) notFound(); if (!res) notFound()
const { title, description, image: _image, date } = res.frontmatter; const { title, description, image: _image, date } = res.frontmatter
const filePath = res.filePath
// File path for edit link const tocs = await getDocsTocs(pathName)
const filePath = `contents/docs/${slug.join("/") || ""}/index.mdx`;
const tocs = await getDocsTocs(pathName);
return ( return (
<div className="flex items-start gap-10"> <div className="flex w-full flex-1 px-0 pb-4 lg:px-8 lg:pb-8 lg:h-[calc(100vh-4rem)]">
<div className="flex-[4.5] pt-5"> <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">
<MobToc tocs={tocs} /> <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} /> <DocsBreadcrumb paths={slug} />
<Typography> <Typography>
<h1 className="text-3xl !-mt-0.5">{title}</h1> <h1 className="-mt-0.5! text-3xl">{title}</h1>
<p className="-mt-4 text-muted-foreground text-[16.5px]">{description}</p> <p className="text-muted-foreground -mt-4 text-[16.5px]">{description}</p>
<div>{res.content}</div> <div>{res.content}</div>
<div <div
className={`my-8 flex items-center border-b-2 border-dashed border-x-muted-foreground ${ 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 ? "justify-between" : "justify-end"
}`} }`}
> >
{docuConfig.repository?.editLink && <EditThisPage filePath={filePath} />} {docuConfig.repository?.editLink && <EditThisPage filePath={filePath} />}
{date && ( {date && (
<p className="text-[13px] text-muted-foreground"> <p className="text-muted-foreground text-[13px]">
Published on {formatDate2(date)} Published on {formatDate2(date)}
</p> </p>
)} )}
@@ -111,7 +102,8 @@ export default async function DocsPage(props: PageProps) {
<Pagination pathname={pathName} /> <Pagination pathname={pathName} />
</Typography> </Typography>
</div> </div>
<Toc path={pathName} /> <Toc tocs={tocs} />
</div> </div>
); </div>
)
} }

View File

@@ -1,4 +1,6 @@
import { Leftbar } from "@/components/leftbar"; import { Leftbar } from "@/components/leftbar";
import DocsNavbar from "@/components/DocsNavbar";
import "@/styles/override.css";
export default function DocsLayout({ export default function DocsLayout({
children, children,
@@ -6,11 +8,16 @@ export default function DocsLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<div className="flex items-start gap-8"> <div className="docs-layout flex flex-col min-h-screen w-full">
<div className="flex flex-1 items-start w-full">
<Leftbar key="leftbar" /> <Leftbar key="leftbar" />
<div className="flex-[5.25] px-1"> <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} {children}
</div> </div>
</main>
</div>
</div> </div>
); );
} }

View File

@@ -1,14 +1,14 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { ThemeProvider } from "@/components/contexts/theme-provider"; import { ThemeProvider } from "@/components/ThemeProvider";
import { Navbar } from "@/components/navbar"; import { Navbar } from "@/components/navbar";
import { GeistSans } from "geist/font/sans"; import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono"; import { GeistMono } from "geist/font/mono";
import { Footer } from "@/components/footer"; import { Footer } from "@/components/footer";
import { SearchProvider } from "@/components/SearchContext";
import docuConfig from "@/docu.json"; import docuConfig from "@/docu.json";
import { Toaster } from "@/components/ui/sonner";
import "@docsearch/css"; import "@docsearch/css";
import "@/styles/algolia.css"; import "@/styles/algolia.css";
import "@/styles/syntax.css"; import "@/styles/override.css";
import "@/styles/globals.css"; import "@/styles/globals.css";
const { meta } = docuConfig; const { meta } = docuConfig;
@@ -35,6 +35,9 @@ const defaultMetadata: Metadata = {
locale: "en_US", locale: "en_US",
type: "website", type: "website",
}, },
other: {
"algolia-site-verification": "6E413CE39E56BB62",
},
}; };
// Dynamic Metadata Getter // Dynamic Metadata Getter
@@ -86,12 +89,13 @@ export default function RootLayout({
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<Navbar /> <SearchProvider>
<main className="sm:container mx-auto w-[90vw] h-auto scroll-smooth"> <Navbar id="main-navbar" />
<main id="main-content" className="sm:container mx-auto w-[90vw] h-auto scroll-smooth">
{children} {children}
</main> </main>
<Footer /> <Footer id="main-footer" />
<Toaster position="top-center" /> </SearchProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

View File

@@ -1,5 +1,5 @@
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { page_routes } from "@/lib/routes-config"; import { page_routes } from "@/lib/routes";
import { import {
ArrowRightIcon, ArrowRightIcon,
LayoutDashboard, LayoutDashboard,
@@ -13,7 +13,7 @@ import Link from "next/link";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import AnimatedShinyText from "@/components/ui/animated-shiny-text"; import AnimatedShinyText from "@/components/ui/animated-shiny-text";
import { getMetadata } from "@/app/layout"; 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({ export const metadata = getMetadata({
title: "WooNooW - The Ultimate WooCommerce Enhancement Suite", title: "WooNooW - The Ultimate WooCommerce Enhancement Suite",
@@ -23,7 +23,7 @@ export default function Home() {
return ( return (
<div className="flex flex-col items-center justify-center px-4 py-8 text-center sm:py-20"> <div className="flex flex-col items-center justify-center px-4 py-8 text-center sm:py-20">
<Link <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" className="mb-5 sm:text-lg flex items-center gap-2 underline underline-offset-4 sm:-mt-12"
> >
<div className="z-10 flex min-h-10 items-center justify-center max-[800px]:mt-10"> <div className="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"> <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" /> <ArrowRightIcon className="ml-1 size-3 transition-transform duration-300 ease-in-out group-hover:translate-x-0.5" />
</AnimatedShinyText> </AnimatedShinyText>
</div> </div>
@@ -41,7 +41,7 @@ export default function Home() {
</Link> </Link>
<div className="w-full max-w-[900px] pb-8"> <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. Fill the Gap. <br />Elevate Your Store.
</h1> </h1>
<p className="mb-8 sm:text-xl text-muted-foreground max-w-2xl mx-auto"> <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"> <div className="flex flex-row items-center gap-6 mb-16">
<Link <Link
href="/docs/getting-started/introduction" href={`/docs${page_routes[0].href}`}
className={buttonVariants({ className={buttonVariants({
className: "px-8 bg-black text-white hover:bg-neutral-800 dark:bg-white dark:text-black dark:hover:bg-neutral-200", className: "px-8 bg-black text-white hover:bg-neutral-800 dark:bg-white dark:text-black dark:hover:bg-neutral-200",
size: "lg", size: "lg",
@@ -74,7 +74,7 @@ export default function Home() {
{/* The Gap Analysis */} {/* The Gap Analysis */}
<div className="w-full max-w-5xl mb-20 text-left"> <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) */} {/* 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"> <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"> <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"> <div className="w-full max-w-6xl">
<h2 className="text-2xl font-bold mb-8">Core Modules</h2> <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"> <Card className="text-left hover:border-green-500/50 transition-colors">
<CardHeader> <CardHeader>
<LayoutDashboard className="size-8 text-green-500 mb-2" /> <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 { usePathname, useRouter } from "next/navigation";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { ROUTES, EachRoute } from "@/lib/routes-config"; import { ROUTES, EachRoute } from "@/lib/routes";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import * as LucideIcons from "lucide-react"; import * as LucideIcons from "lucide-react";
import { ChevronsUpDown, Check, type LucideIcon } from "lucide-react"; import { ChevronsUpDown, Check, type LucideIcon } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
interface ContextPopoverProps { interface ContextPopoverProps {
className?: string; className?: string;
@@ -62,7 +63,7 @@ export default function ContextPopover({ className }: ContextPopoverProps) {
<Button <Button
variant="ghost" variant="ghost"
className={cn( 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", "hover:bg-transparent hover:text-foreground",
className className
)} )}
@@ -74,7 +75,7 @@ export default function ContextPopover({ className }: ContextPopoverProps) {
</span> </span>
)} )}
<span className="truncate text-sm"> <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> </span>
</div> </div>
<ChevronsUpDown className="h-4 w-4 text-foreground/50" /> <ChevronsUpDown className="h-4 w-4 text-foreground/50" />
@@ -96,7 +97,7 @@ export default function ContextPopover({ className }: ContextPopoverProps) {
key={route.href} key={route.href}
onClick={() => router.push(contextPath)} onClick={() => router.push(contextPath)}
className={cn( 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", "text-left outline-none transition-colors",
isActive isActive
? "bg-primary/20 text-primary dark:bg-accent/20 dark:text-accent" ? "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() { interface AlgoliaSearchProps {
const appId = process.env.NEXT_PUBLIC_ALGOLIA_DOCSEARCH_APP_ID; className?: string
const apiKey = process.env.NEXT_PUBLIC_ALGOLIA_DOCSEARCH_API_KEY; }
const indexName = process.env.NEXT_PUBLIC_ALGOLIA_DOCSEARCH_INDEX_NAME;
export default function AlgoliaSearch({ className }: AlgoliaSearchProps) {
const { appId, apiKey, indexName } = algoliaConfig
if (!appId || !apiKey || !indexName) { if (!appId || !apiKey || !indexName) {
console.error( console.error("DocSearch credentials are not set in the environment variables.")
"DocSearch credentials are not set in the environment variables."
);
return ( return (
<button className="text-sm text-muted-foreground" disabled> <button className="text-muted-foreground text-sm" disabled>
Search... (misconfigured) Search... (misconfigured)
</button> </button>
); )
} }
return ( return (
<div className="docsearch"> <div className={cn("docsearch", className)}>
<DocSearch <DocSearch
appId={appId} appId={appId}
apiKey={apiKey} apiKey={apiKey}
@@ -28,5 +29,5 @@ export default function DocSearchComponent() {
placeholder="Type something to search..." placeholder="Type something to search..."
/> />
</div> </div>
); )
} }

View File

@@ -10,7 +10,7 @@ import { Fragment } from "react";
export default function DocsBreadcrumb({ paths }: { paths: string[] }) { export default function DocsBreadcrumb({ paths }: { paths: string[] }) {
return ( return (
<div className="pb-5 max-lg:pt-12"> <div className="pb-5 max-lg:pt-6">
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>
<BreadcrumbItem> <BreadcrumbItem>
@@ -21,10 +21,7 @@ export default function DocsBreadcrumb({ paths }: { paths: string[] }) {
<BreadcrumbSeparator /> <BreadcrumbSeparator />
<BreadcrumbItem> <BreadcrumbItem>
{index < paths.length - 1 ? ( {index < paths.length - 1 ? (
<BreadcrumbLink <BreadcrumbLink className="a">
className="a"
href={`/docs/${paths.slice(0, index + 1).join("/")}`}
>
{toTitleCase(path)} {toTitleCase(path)}
</BreadcrumbLink> </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 [isVisible, setIsVisible] = useState(false);
const checkScroll = useCallback(() => { 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 // Calculate 50% of viewport height
const halfViewportHeight = window.innerHeight * 0.5; const halfViewportHeight = window.innerHeight * 0.5;
// Check if scrolled past half viewport height (plus any offset) // 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 // Only update state if it changes to prevent unnecessary re-renders
if (scrolledPastHalfViewport !== isVisible) { if (scrolledPastHalfViewport !== isVisible) {
@@ -42,21 +46,24 @@ export function ScrollToTop({
timeoutId = setTimeout(checkScroll, 100); timeoutId = setTimeout(checkScroll, 100);
}; };
window.addEventListener('scroll', handleScroll, { passive: true }); const container = document.getElementById("scroll-container") || window;
container.addEventListener('scroll', handleScroll, { passive: true });
// Cleanup // Cleanup
return () => { return () => {
window.removeEventListener('scroll', handleScroll); container.removeEventListener('scroll', handleScroll);
if (timeoutId) clearTimeout(timeoutId); if (timeoutId) clearTimeout(timeoutId);
}; };
}, [checkScroll]); }, [checkScroll]);
const scrollToTop = useCallback((e: React.MouseEvent) => { const scrollToTop = useCallback((e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
window.scrollTo({ const container = document.getElementById("scroll-container");
top: 0, if (container) {
behavior: 'smooth' container.scrollTo({ top: 0, behavior: 'smooth' });
}); } else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, []); }, []);
if (!isVisible) return null; if (!isVisible) return null;
@@ -75,11 +82,11 @@ export function ScrollToTop({
onClick={scrollToTop} onClick={scrollToTop}
className={cn( className={cn(
"inline-flex items-center text-sm text-muted-foreground hover:text-foreground", "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" 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> <span>Scroll to Top</span>
</Link> </Link>
</div> </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 { useRouter } from "next/navigation"
import { useEffect, useMemo, useState, useRef } from "react"; import { useEffect, useMemo, useState, useRef } from "react"
import { ArrowUpIcon, ArrowDownIcon, CornerDownLeftIcon, FileTextIcon } from "lucide-react"; import { ArrowUpIcon, ArrowDownIcon, CornerDownLeftIcon, FileTextIcon } from "lucide-react"
import Anchor from "./anchor"; import Anchor from "./anchor"
import { advanceSearch, cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
import { ScrollArea } from "@/components/ui/scroll-area"; import { advanceSearch } from "@/lib/search/built-in"
import { page_routes } from "@/lib/routes-config"; import { ScrollArea } from "@/components/ui/scroll-area"
import { page_routes } from "@/lib/routes"
import { import {
DialogContent, DialogContent,
DialogHeader, DialogHeader,
@@ -14,63 +15,63 @@ import {
DialogClose, DialogClose,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog"
type ContextInfo = { type ContextInfo = {
icon: string; icon: string
description: string; description: string
title?: string; title?: string
}; }
type SearchResult = { type SearchResult = {
title: string; title: string
href: string; href: string
noLink?: boolean; noLink?: boolean
items?: undefined; items?: undefined
score?: number; score?: number
context?: ContextInfo; context?: ContextInfo
}; }
const paddingMap = { const paddingMap = {
1: "pl-2", 1: "pl-2",
2: "pl-4", 2: "pl-4",
3: "pl-10", 3: "pl-10",
} as const; } as const
interface SearchModalProps { interface SearchModalProps {
isOpen: boolean; isOpen: boolean
setIsOpen: (open: boolean) => void; setIsOpen: (open: boolean) => void
} }
export function SearchModal({ isOpen, setIsOpen }: SearchModalProps) { export function SearchModal({ isOpen, setIsOpen }: SearchModalProps) {
const router = useRouter(); const router = useRouter()
const [searchedInput, setSearchedInput] = useState(""); const [searchedInput, setSearchedInput] = useState("")
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0)
const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const itemRefs = useRef<(HTMLDivElement | null)[]>([])
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setSearchedInput(""); setSearchedInput("")
} }
}, [isOpen]); }, [isOpen])
const filteredResults = useMemo<SearchResult[]>(() => { const filteredResults = useMemo<SearchResult[]>(() => {
const trimmedInput = searchedInput.trim(); const trimmedInput = searchedInput.trim()
if (trimmedInput.length < 3) { if (trimmedInput.length < 3) {
return page_routes return page_routes
.filter((route) => !route.href.endsWith('/')) .filter((route) => !route.href.endsWith("/"))
.slice(0, 6) .slice(0, 6)
.map((route: { title: string; href: string; noLink?: boolean; context?: ContextInfo }) => ({ .map((route: { title: string; href: string; noLink?: boolean; context?: ContextInfo }) => ({
title: route.title, title: route.title,
href: route.href, href: route.href,
noLink: route.noLink, noLink: route.noLink,
context: route.context, context: route.context,
})); }))
} }
return advanceSearch(trimmedInput) as unknown as SearchResult[]; return advanceSearch(trimmedInput) as unknown as SearchResult[]
}, [searchedInput]); }, [searchedInput])
// useEffect(() => { // useEffect(() => {
// setSelectedIndex(0); // setSelectedIndex(0);
@@ -78,39 +79,39 @@ export function SearchModal({ isOpen, setIsOpen }: SearchModalProps) {
useEffect(() => { useEffect(() => {
const handleNavigation = (event: KeyboardEvent) => { const handleNavigation = (event: KeyboardEvent) => {
if (!isOpen || filteredResults.length === 0) return; if (!isOpen || filteredResults.length === 0) return
if (event.key === "ArrowDown") { if (event.key === "ArrowDown") {
event.preventDefault(); event.preventDefault()
setSelectedIndex((prev) => (prev + 1) % filteredResults.length); setSelectedIndex((prev) => (prev + 1) % filteredResults.length)
} else if (event.key === "ArrowUp") { } else if (event.key === "ArrowUp") {
event.preventDefault(); event.preventDefault()
setSelectedIndex((prev) => (prev - 1 + filteredResults.length) % filteredResults.length); setSelectedIndex((prev) => (prev - 1 + filteredResults.length) % filteredResults.length)
} else if (event.key === "Enter") { } else if (event.key === "Enter") {
event.preventDefault(); event.preventDefault()
const selectedItem = filteredResults[selectedIndex]; const selectedItem = filteredResults[selectedIndex]
if (selectedItem) { if (selectedItem) {
router.push(`/docs${selectedItem.href}`); router.push(`/docs${selectedItem.href}`)
setIsOpen(false); setIsOpen(false)
}
} }
} }
};
window.addEventListener("keydown", handleNavigation); window.addEventListener("keydown", handleNavigation)
return () => window.removeEventListener("keydown", handleNavigation); return () => window.removeEventListener("keydown", handleNavigation)
}, [isOpen, filteredResults, selectedIndex, router, setIsOpen]); }, [isOpen, filteredResults, selectedIndex, router, setIsOpen])
useEffect(() => { useEffect(() => {
if (itemRefs.current[selectedIndex]) { if (itemRefs.current[selectedIndex]) {
itemRefs.current[selectedIndex]?.scrollIntoView({ itemRefs.current[selectedIndex]?.scrollIntoView({
behavior: "smooth", behavior: "smooth",
block: "nearest", block: "nearest",
}); })
} }
}, [selectedIndex]); }, [selectedIndex])
return ( 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> <DialogHeader>
<DialogTitle className="sr-only">Search Documentation</DialogTitle> <DialogTitle className="sr-only">Search Documentation</DialogTitle>
<DialogDescription className="sr-only">Search through the documentation</DialogDescription> <DialogDescription className="sr-only">Search through the documentation</DialogDescription>
@@ -119,36 +120,35 @@ export function SearchModal({ isOpen, setIsOpen }: SearchModalProps) {
<input <input
value={searchedInput} value={searchedInput}
onChange={(e) => { onChange={(e) => {
setSearchedInput(e.target.value); setSearchedInput(e.target.value)
setSelectedIndex(0); setSelectedIndex(0)
}} }}
placeholder="Type something to search..." placeholder="Type something to search..."
autoFocus autoFocus
className="h-14 px-6 bg-transparent border-b text-[14px] outline-none w-full" className="h-14 w-full border-b bg-transparent px-6 text-[14px] outline-none"
aria-label="Search documentation" aria-label="Search documentation"
/> />
{filteredResults.length == 0 && searchedInput && ( {filteredResults.length == 0 && searchedInput && (
<p className="text-muted-foreground mx-auto mt-2 text-sm"> <p className="text-muted-foreground mx-auto mt-2 text-sm">
No results found for{" "} No results found for <span className="text-primary">{`"${searchedInput}"`}</span>
<span className="text-primary">{`"${searchedInput}"`}</span>
</p> </p>
)} )}
<ScrollArea className="max-h-[400px] overflow-y-auto"> <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) => { {filteredResults.map((item, index) => {
const level = (item.href.split("/").slice(1).length - 1) as keyof typeof paddingMap; const level = (item.href.split("/").slice(1).length - 1) as keyof typeof paddingMap
const paddingClass = paddingMap[level] || 'pl-2'; const paddingClass = paddingMap[level] || "pl-2"
const isActive = index === selectedIndex; const isActive = index === selectedIndex
return ( return (
<DialogClose key={item.href} asChild> <DialogClose key={item.href} asChild>
<Anchor <Anchor
ref={(el) => { ref={(el) => {
itemRefs.current[index] = el as HTMLDivElement | null; itemRefs.current[index] = el as HTMLDivElement | null
}} }}
className={cn( 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", isActive && "bg-primary/20 dark:bg-primary/30",
paddingClass paddingClass
)} )}
@@ -157,46 +157,44 @@ export function SearchModal({ isOpen, setIsOpen }: SearchModalProps) {
> >
<div <div
className={cn( 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" level > 1 && "border-l pl-4"
)} )}
> >
<div className="flex items-center"> <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> <span>{item.title}</span>
</div> </div>
{isActive && ( {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> <span>Return</span>
<CornerDownLeftIcon className="h-3 w-3 ml-1" /> <CornerDownLeftIcon className="ml-1 h-3 w-3" />
</div> </div>
)} )}
</div> </div>
</Anchor> </Anchor>
</DialogClose> </DialogClose>
); )
})} })}
</div> </div>
</ScrollArea> </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"> <div className="flex items-center gap-2">
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2"> <span className="dark:bg-accent/15 rounded border bg-slate-200 p-2">
<ArrowUpIcon className="w-3 h-3" /> <ArrowUpIcon className="h-3 w-3" />
</span> </span>
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2"> <span className="dark:bg-accent/15 rounded border bg-slate-200 p-2">
<ArrowDownIcon className="w-3 h-3" /> <ArrowDownIcon className="h-3 w-3" />
</span> </span>
<p className="text-muted-foreground">to navigate</p> <p className="text-muted-foreground">to navigate</p>
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2"> <span className="dark:bg-accent/15 rounded border bg-slate-200 p-2">
<CornerDownLeftIcon className="w-3 h-3" /> <CornerDownLeftIcon className="h-3 w-3" />
</span> </span>
<p className="text-muted-foreground">to select</p> <p className="text-muted-foreground">to select</p>
<span className="dark:bg-accent/15 bg-slate-200 border rounded px-2 py-1"> <span className="dark:bg-accent/15 rounded border bg-slate-200 px-2 py-1">esc</span>
esc
</span>
<p className="text-muted-foreground">to close</p> <p className="text-muted-foreground">to close</p>
</div> </div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
); )
} }

View File

@@ -1,31 +1,36 @@
"use client"; "use client"
import { CommandIcon, SearchIcon } from "lucide-react"; import { CommandIcon, SearchIcon } from "lucide-react"
import { DialogTrigger } from "@/components/ui/dialog"; import { DialogTrigger } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
export function SearchTrigger() { interface SearchTriggerProps {
className?: string
}
export function SearchTrigger({ className }: SearchTriggerProps) {
return ( return (
<DialogTrigger asChild> <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="flex items-center">
<div className="md:hidden p-2 -ml-2"> <div className="-ml-2 block p-2 lg:hidden">
<SearchIcon className="h-5 w-5 text-muted-foreground" /> <SearchIcon className="text-muted-foreground h-6 w-6" />
</div> </div>
<div className="hidden md:block w-full"> <div className="hidden w-full lg:block">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <SearchIcon className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input <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" placeholder="Search"
readOnly // This input is for display only 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"> <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="w-3 h-3" /> <CommandIcon className="h-3 w-3" />
<span>K</span> <span>K</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</DialogTrigger> </DialogTrigger>
); )
} }

View File

@@ -106,7 +106,7 @@ export function Sponsor() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex flex-col justify-center gap-2 p-4 border rounded-lg hover:shadow transition-shadow" 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 <Image
src={item.image} src={item.image}
alt={item.title} alt={item.title}

View File

@@ -17,9 +17,9 @@ export function ModeToggle() {
// Jika belum mounted, jangan render apapun untuk menghindari mismatch // Jika belum mounted, jangan render apapun untuk menghindari mismatch
if (!mounted) { if (!mounted) {
return ( return (
<div className="flex items-center gap-1 rounded-full border border-border bg-background/50 p-1"> <div className="flex items-center gap-1 rounded-full border border-border bg-background/50 p-0.5">
<div className="rounded-full p-1 w-8 h-8" /> <div className="rounded-full p-0 w-1 h-1" />
<div className="rounded-full p-1 w-8 h-8" /> <div className="rounded-full p-0 w-1 h-1" />
</div> </div>
); );
} }
@@ -43,29 +43,29 @@ export function ModeToggle() {
type="single" type="single"
value={activeTheme} value={activeTheme}
onValueChange={handleToggle} 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 <ToggleGroupItem
value="light" value="light"
size="sm" size="xs"
aria-label="Light Mode" 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-primary text-primary-foreground"
: "bg-transparent hover:bg-muted/50" : "bg-transparent hover:bg-muted/50"
}`} }`}
> >
<Sun className="h-4 w-4" /> <Sun className="h-0.5 w-0.5" />
</ToggleGroupItem> </ToggleGroupItem>
<ToggleGroupItem <ToggleGroupItem
value="dark" value="dark"
size="sm" size="xs"
aria-label="Dark Mode" 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-primary text-primary-foreground"
: "bg-transparent hover:bg-muted/50" : "bg-transparent hover:bg-muted/50"
}`} }`}
> >
<Moon className="h-4 w-4" /> <Moon className="h-0.5 w-0.5" />
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </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 Link from "next/link";
import { ModeToggle } from "@/components/theme-toggle"; import { ModeToggle } from "@/components/ThemeToggle";
import docuData from "@/docu.json"; import docuData from "@/docu.json";
import * as LucideIcons from "lucide-react"; import * as LucideIcons from "lucide-react";
@@ -20,10 +20,14 @@ const docuConfig = docuData as {
footer: FooterConfig; footer: FooterConfig;
}; };
export function Footer() { interface FooterProps {
id?: string;
}
export function Footer({ id }: FooterProps) {
const { footer } = docuConfig; const { footer } = docuConfig;
return ( 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="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"> <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"> <p className="text-muted-foreground">

View File

@@ -1,101 +1,69 @@
"use client" "use client"
import { useState } from "react"; import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTrigger } from "@/components/ui/sheet"
import { import { Logo, NavMenu } from "@/components/navbar"
Sheet, import { Button } from "@/components/ui/button"
SheetClose, import { PanelRight } from "lucide-react"
SheetContent, import { DialogTitle, DialogDescription } from "@/components/ui/dialog"
SheetHeader, import { ScrollArea } from "@/components/ui/scroll-area"
SheetTrigger, import DocsMenu from "@/components/DocsMenu"
} from "@/components/ui/sheet"; import { ModeToggle } from "@/components/ThemeToggle"
import { Logo, NavMenu } from "@/components/navbar"; import ContextPopover from "@/components/ContextPopover"
import { Button } from "@/components/ui/button"; import Search from "@/components/SearchBox"
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>
)
}
export function Leftbar() { export function Leftbar() {
const [collapsed, setCollapsed] = useState(false);
const toggleCollapse = () => setCollapsed(prev => !prev);
return ( return (
<aside <aside className="sticky top-0 hidden h-screen w-[280px] shrink-0 flex-col lg:flex">
className={`sticky lg:flex hidden top-16 h-[calc(100vh-4rem)] border-r bg-background transition-all duration-300 {/* Logo */}
${collapsed ? "w-[24px]" : "w-[280px]"} flex flex-col pr-2`} <div className="flex h-14 shrink-0 items-center px-5">
> <Logo />
<ToggleButton collapsed={collapsed} onToggle={toggleCollapse} /> </div>
{/* Scrollable Content */}
<ScrollArea className="flex-1 px-0.5 pb-4"> <div className="flex shrink-0 items-center gap-2 px-4 pb-4">
{!collapsed && ( <Search className="min-w-[250px] max-w-[250px]" />
</div>
{/* Scrollable Navigation */}
<ScrollArea className="flex-1 px-4">
<div className="space-y-2"> <div className="space-y-2">
<ContextPopover /> <ContextPopover />
<DocsMenu /> <DocsMenu />
</div> </div>
)}
</ScrollArea> </ScrollArea>
{/* Bottom: Theme Toggle */}
<div className="flex px-4 py-3">
<ModeToggle />
</div>
</aside> </aside>
); )
} }
export function SheetLeftbar() { export function SheetLeftbar() {
return ( return (
<Sheet> <Sheet>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant="ghost" size="icon" className="max-lg:flex hidden"> <Button variant="ghost" size="icon" className="hidden max-md:flex">
<LayoutGrid /> <PanelRight className="text-muted-foreground h-6 w-6 shrink-0" />
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent className="flex flex-col gap-4 px-0" side="left"> <SheetContent className="flex flex-col gap-4 px-0" side="right">
<DialogTitle className="sr-only">Navigation Menu</DialogTitle> <DialogTitle className="sr-only">Navigation Menu</DialogTitle>
<DialogDescription className="sr-only"> <DialogDescription className="sr-only">
Main navigation menu with links to different sections Main navigation menu with links to different sections
</DialogDescription> </DialogDescription>
<SheetHeader> <SheetHeader>
<SheetClose className="px-5" asChild> <SheetClose className="px-4" asChild>
<span className="px-2"><Logo /></span> <div className="flex items-center justify-between">
<ModeToggle />
</div>
</SheetClose> </SheetClose>
</SheetHeader> </SheetHeader>
<div className="flex flex-col gap-4 overflow-y-auto"> <div className="flex flex-col gap-4 overflow-y-auto">
<div className="flex flex-col gap-2.5 mt-3 mx-2 px-5"> <div className="mx-2 mt-3 flex flex-col gap-2.5 px-5">
<NavMenu isSheet /> <NavMenu />
</div>
<div className="mx-2 px-5 space-y-2">
<ContextPopover />
<DocsMenu isSheet />
</div>
<div className="flex w-2/4 px-5">
<ModeToggle />
</div> </div>
</div> </div>
</SheetContent> </SheetContent>
</Sheet> </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" "use client"
import React, { ReactNode } from "react"; import React, { ReactNode } from "react"
import clsx from "clsx"; import clsx from "clsx"
import { AccordionGroupContext } from "@/components/contexts/AccordionContext"; import { AccordionGroupProvider } from "@/components/markdown/AccordionContext"
interface AccordionGroupProps { interface AccordionGroupProps {
children: ReactNode; children: ReactNode
className?: string; className?: string
} }
const AccordionGroup: React.FC<AccordionGroupProps> = ({ children, className }) => { const AccordionGroup: React.FC<AccordionGroupProps> = ({ children, className }) => {
return ( return (
// Wrap all children with the AccordionGroupContext.Provider <AccordionGroupProvider>
// so that any nested accordions know they are inside a group. <div className={clsx("overflow-hidden rounded-lg border", className)}>{children}</div>
// This enables group-specific behavior in child components. </AccordionGroupProvider>
<AccordionGroupContext.Provider value={{ inGroup: true }}> )
<div }
className={clsx(
"border rounded-lg overflow-hidden",
className
)}
>
{children}
</div>
</AccordionGroupContext.Provider>
);
};
export default AccordionGroup; export default AccordionGroup

View File

@@ -1,62 +1,61 @@
"use client"; "use client"
import { ReactNode, useState, useContext } from 'react'; import { ReactNode, useContext, useState } from "react"
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from "lucide-react"
import * as Icons from "lucide-react"; import * as Icons from "lucide-react"
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils"
import { AccordionGroupContext } from '@/components/contexts/AccordionContext'; import { AccordionGroupContext } from "@/components/markdown/AccordionContext"
type AccordionProps = { type AccordionProps = {
title: string; title: string
children?: ReactNode; children?: ReactNode
defaultOpen?: boolean; icon?: keyof typeof Icons
icon?: keyof typeof Icons; }
};
const Accordion: React.FC<AccordionProps> = ({ const Accordion: React.FC<AccordionProps> = ({ title, children, icon }: AccordionProps) => {
title, const groupContext = useContext(AccordionGroupContext)
children, const isInGroup = groupContext?.inGroup === true
defaultOpen = false, const groupOpen = groupContext?.openTitle === title
icon, const setGroupOpen = groupContext?.setOpenTitle
}: AccordionProps) => { const [localOpen, setLocalOpen] = useState(false)
const groupContext = useContext(AccordionGroupContext);
const isInGroup = groupContext?.inGroup === true; const isOpen = isInGroup ? groupOpen : localOpen
const [isOpen, setIsOpen] = useState(defaultOpen);
const Icon = icon ? (Icons[icon] as React.FC<{ className?: string }>) : null; const handleToggle = () => {
if (isInGroup && setGroupOpen) {
setGroupOpen(groupOpen ? null : title)
} else {
setLocalOpen(!localOpen)
}
}
const Icon = icon ? (Icons[icon] as React.FC<{ className?: string }>) : null
// The main wrapper div for the accordion.
// All styling logic for the accordion container is handled here.
return ( return (
<div <div
className={cn( className={cn(
// Style for STANDALONE: full card with border & shadow !isInGroup && "rounded-lg border shadow-sm",
!isInGroup && "border rounded-lg shadow-sm", isInGroup && "border-border border-b last:border-b-0"
// Style for IN GROUP: only a bottom border separator
isInGroup && "border-b last:border-b-0 border-border"
)} )}
> >
<button <button
type="button" type="button"
onClick={() => setIsOpen(!isOpen)} onClick={handleToggle}
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" 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 <ChevronRight
className={cn( className={cn(
"w-4 h-4 text-muted-foreground transition-transform duration-200 flex-shrink-0", "text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200",
isOpen && "rotate-90" isOpen && "rotate-90"
)} )}
/> />
{Icon && <Icon className="text-foreground w-4 h-4 flex-shrink-0" />} {Icon && <Icon className="text-foreground h-4 w-4 shrink-0" />}
<h3 className="font-medium text-base text-foreground !m-0 leading-none">{title}</h3> <h3 className="text-foreground m-0! text-base font-medium">{title}</h3>
</button> </button>
{isOpen && ( {isOpen && <div className="dark:bg-muted/10 bg-muted/15 px-4 py-3">{children}</div>}
<div className="px-4 py-3 dark:bg-muted/10 bg-muted/15">
{children}
</div> </div>
)} )
</div> }
);
};
export default Accordion; 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", "bg-card text-card-foreground border-border",
"hover:bg-accent/5 hover:border-accent/30", "hover:bg-accent/5 hover:border-accent/30",
"flex gap-2", "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 className
)} )}
> >
{Icon && <Icon className="w-5 h-5 text-primary flex-shrink-0" />} {Icon && <Icon className={clsx("w-5 h-5 text-primary shrink-0", horizontal && "mt-0.5")} />}
<div className="flex-1 min-w-0 my-auto h-full"> <div className="flex-1 min-w-0">
<span className="text-base font-semibold text-foreground">{title}</span> <div className="text-base font-semibold text-foreground leading-6">{title}</div>
<div className="text-sm text-muted-foreground -mt-3">{children}</div> <div className="text-sm text-muted-foreground -mt-3">{children}</div>
</div> </div>
</div> </div>

View File

@@ -19,7 +19,7 @@ export default function Copy({ content }: { content: string }) {
return ( return (
<Button <Button
variant="secondary" variant="secondary"
className="border" className="border cursor-copy"
size="xs" size="xs"
onClick={handleCopy} onClick={handleCopy}
> >

View File

@@ -24,7 +24,7 @@ const FileComponent = ({ name }: FileProps) => {
tabIndex={-1} tabIndex={-1}
> >
<FileIcon className={` <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'} ${isHovered ? 'text-accent' : 'text-muted-foreground'}
`} /> `} />
<span className="font-mono text-sm text-foreground truncate">{name}</span> <span className="font-mono text-sm text-foreground truncate">{name}</span>
@@ -61,7 +61,7 @@ const FolderComponent = ({ name, children }: FileProps) => {
{hasChildren ? ( {hasChildren ? (
<ChevronRight <ChevronRight
className={` 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' : ''} ${isOpen ? 'rotate-90' : ''}
${isHovered ? 'text-foreground/70' : 'text-muted-foreground'} ${isHovered ? 'text-foreground/70' : 'text-muted-foreground'}
`} `}
@@ -71,12 +71,12 @@ const FolderComponent = ({ name, children }: FileProps) => {
)} )}
{isOpen ? ( {isOpen ? (
<FolderOpen className={` <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'} ${isHovered ? 'text-accent' : 'text-muted-foreground'}
`} /> `} />
) : ( ) : (
<FolderIcon className={` <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'} ${isHovered ? 'text-accent/80' : 'text-muted-foreground/80'}
`} /> `} />
)} )}

View File

@@ -1,5 +1,10 @@
import { ComponentProps } from "react"; "use client";
import { ComponentProps, useState, useEffect } from "react";
import NextImage from "next/image"; 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 Height = ComponentProps<typeof NextImage>["height"];
type Width = ComponentProps<typeof NextImage>["width"]; type Width = ComponentProps<typeof NextImage>["width"];
@@ -15,15 +20,110 @@ export default function Image({
height = 350, height = 350,
...props ...props
}: ImageProps) { }: ImageProps) {
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; if (!src) return null;
return ( 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 <NextImage
src={src} src={src}
alt={alt} alt={alt}
width={width as Width} width={width as Width}
height={height as Height} height={height as Height}
quality={40} quality={85}
className="w-full h-auto rounded-lg transition-transform duration-300 group-hover:scale-[1.01]"
{...props} {...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 { cn } from "@/lib/utils";
import clsx from "clsx"; import { cva, type VariantProps } from "class-variance-authority";
import { PropsWithChildren } from "react";
import { import {
Info, Info,
AlertTriangle, AlertTriangle,
ShieldAlert, ShieldAlert,
CheckCircle, CheckCircle2,
} from "lucide-react"; } from "lucide-react";
import React from "react";
type NoteProps = PropsWithChildren & { const noteVariants = cva(
title?: string; "relative w-full rounded-lg border border-l-4 p-4 mb-4 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
type?: "note" | "danger" | "warning" | "success"; {
}; 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 = { const iconMap = {
note: <Info size={16} className="text-blue-500" />, note: Info,
danger: <ShieldAlert size={16} className="text-red-500" />, danger: ShieldAlert,
warning: <AlertTriangle size={16} className="text-orange-500" />, warning: AlertTriangle,
success: <CheckCircle size={16} className="text-green-500" />, success: CheckCircle2,
}; };
interface NoteProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof noteVariants> {
title?: string;
type?: "note" | "danger" | "warning" | "success";
}
export default function Note({ export default function Note({
children, className,
title = "Note", title = "Note",
type = "note", type = "note",
children,
...props
}: NoteProps) { }: NoteProps) {
const noteClassNames = clsx({ const Icon = iconMap[type] || Info;
"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",
});
return ( return (
<div <div
className={cn( className={cn(noteVariants({ variant: type }), className)}
"border rounded-md px-5 pb-0.5 mt-5 mb-7 text-sm tracking-wide", {...props}
noteClassNames
)}
> >
<div className="flex items-center gap-2 font-bold -mb-2.5 pt-6"> <Icon className="h-5 w-5" />
{iconMap[type]} <div className="pl-8">
<span className="text-base">{title}:</span> <h5 className="mb-1 font-medium leading-none tracking-tight">
</div> {title}
</h5>
<div className="text-sm [&_p]:leading-relaxed opacity-90">
{children} {children}
</div> </div>
</div>
</div>
); );
} }

View File

@@ -11,7 +11,7 @@ import {
SiSwift, SiSwift,
SiKotlin, SiKotlin,
SiHtml5, SiHtml5,
SiCss3, SiCss,
SiSass, SiSass,
SiPostgresql, SiPostgresql,
SiGraphql, SiGraphql,
@@ -68,7 +68,7 @@ const LanguageIcon = ({ lang }: { lang: string }) => {
js: <SiJavascript {...iconProps} />, js: <SiJavascript {...iconProps} />,
javascript: <SiJavascript {...iconProps} />, javascript: <SiJavascript {...iconProps} />,
html: <SiHtml5 {...iconProps} />, html: <SiHtml5 {...iconProps} />,
css: <SiCss3 {...iconProps} />, css: <SiCss {...iconProps} />,
scss: <SiSass {...iconProps} />, scss: <SiSass {...iconProps} />,
sass: <SiSass {...iconProps} />, sass: <SiSass {...iconProps} />,
}; };

View File

@@ -12,25 +12,29 @@ function Release({ version, title, date, children }: ReleaseProps) {
return ( return (
<div className="mb-16 group"> <div className="mb-16 group">
<div className="mb-6"> <div className="flex items-center gap-3 mt-6 mb-2">
<div className="flex items-center gap-3 mb-2"> <div
<div className="bg-primary/10 text-primary border-2 border-primary/20 rounded-full px-4 py-1.5 text-base font-medium"> 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} v{version}
</div> </div>
{date && ( {date && (
<div className="text-muted-foreground text-sm"> <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', { {new Date(date).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric' day: 'numeric'
})} })}
</time>
</div> </div>
)} )}
</div> </div>
<h2 className="text-2xl font-bold text-foreground/90 mb-3"> <h3 className="text-2xl font-bold text-foreground/90 mb-6 mt-0!">
{title} {title}
</h2> </h3>
</div>
<div className="space-y-8"> <div className="space-y-8">
{children} {children}
</div> </div>

View File

@@ -19,7 +19,7 @@ const Tooltip: React.FC<TooltipProps> = ({ text, tip }) => {
{text} {text}
</span> </span>
{visible && ( {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} {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 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> </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,44 +1,91 @@
import { ArrowUpRight } from "lucide-react"; "use client"
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
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 ( return (
<nav className="sticky top-0 z-50 w-full h-16 border-b bg-background"> <div className="sticky top-0 z-50 w-full">
<div className="sm:container mx-auto w-[95vw] h-full flex items-center justify-between md:gap-2"> <nav id={id} className="bg-background h-16 w-full border-b">
<div className="flex items-center gap-5"> <div className="mx-auto flex h-full w-[95vw] items-center justify-between sm:container md:gap-2">
<SheetLeftbar />
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="hidden lg:flex"> <div className="flex">
<Logo /> <Logo />
</div> </div>
</div> </div>
</div> <div className="flex items-center gap-0 max-md:flex-row-reverse md:gap-2">
<div className="flex items-center gap-2"> <div className="text-muted-foreground hidden items-center gap-4 text-sm font-medium md:flex">
<div className="items-center hidden gap-4 text-sm font-medium lg:flex text-muted-foreground">
<NavMenu /> <NavMenu />
</div> </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 /> <Search />
</div> </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() { export function Logo() {
const { navbar } = docuConfig; // Extract navbar from JSON const { navbar } = docuConfig
return ( return (
<Link href="/" className="flex items-center gap-1.5"> <Link href="/" className="flex items-center gap-1.5">
<div className="relative w-8 h-8"> <div className="relative h-8 w-8">
<Image <Image
src={navbar.logo.src} src={navbar.logo.src}
alt={navbar.logo.alt} alt={navbar.logo.alt}
@@ -47,41 +94,65 @@ export function Logo() {
className="object-contain" className="object-contain"
/> />
</div> </div>
<h2 className="font-bold font-code text-md">{navbar.logoText}</h2> <h2 className="font-code dark:text-accent text-primary text-lg font-bold">
{navbar.logoText}
</h2>
</Link> </Link>
); )
} }
export function NavMenu({ isSheet = false }) { // Desktop NavMenu — horizontal list
const { navbar } = docuConfig; // Extract navbar from JSON export function NavMenu() {
const { navbar } = docuConfig
return ( return (
<> <>
{navbar?.menu?.map((item) => { {navbar?.menu?.map((item) => {
const isExternal = item.href.startsWith("http"); const isExternal = item.href.startsWith("http")
return (
const Comp = (
<Anchor <Anchor
key={`${item.title}-${item.href}`} key={`${item.title}-${item.href}`}
activeClassName="text-primary dark:text-accent md:font-semibold font-medium" activeClassName="text-primary dark:text-accent md:font-semibold font-medium"
absolute 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} href={item.href}
target={isExternal ? "_blank" : undefined} target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined} rel={isExternal ? "noopener noreferrer" : undefined}
> >
{item.title} {item.title}
{isExternal && <ArrowUpRight className="w-4 h-4 text-foreground/80" />} {isExternal && <ArrowUpRight className="text-foreground/80 h-4 w-4" />}
</Anchor> </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: className:
"no-underline w-full flex flex-col pl-3 !py-8 !items-start", "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"> <span className="flex items-center text-xs">
<ChevronLeftIcon className="w-[1rem] h-[1rem] mr-1" /> <ChevronLeftIcon className="w-[1rem] h-[1rem] mr-1" />
@@ -34,7 +34,7 @@ export default function Pagination({ pathname }: { pathname: string }) {
className: className:
"no-underline w-full flex flex-col pr-3 !py-8 !items-end", "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"> <span className="flex items-center text-xs">
Next 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 Anchor from "./anchor";
import { import {
Collapsible, Collapsible,
@@ -27,7 +27,7 @@ export default function SubLink({
parentHref = "", parentHref = "",
}: SubLinkProps) { }: SubLinkProps) {
const path = usePathname(); const path = usePathname();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(level === 0);
// Full path including parent's href // Full path including parent's href
const fullHref = `${parentHref}${href}`; const fullHref = `${parentHref}${href}`;
@@ -54,6 +54,7 @@ export default function SubLink({
<Anchor <Anchor
activeClassName={!hasActiveChild ? "dark:text-accent text-primary font-medium" : ""} activeClassName={!hasActiveChild ? "dark:text-accent text-primary font-medium" : ""}
href={fullHref} href={fullHref}
data-search-lvl0={level === 0 && hasActiveChild ? "true" : undefined}
className={cn( className={cn(
"text-foreground/80 hover:text-foreground transition-colors", "text-foreground/80 hover:text-foreground transition-colors",
hasActiveChild && "font-medium text-foreground" hasActiveChild && "font-medium text-foreground"
@@ -61,7 +62,7 @@ export default function SubLink({
> >
{title} {title}
</Anchor> </Anchor>
), [title, fullHref, hasActiveChild]); ), [title, fullHref, hasActiveChild, level]);
const titleOrLink = !noLink ? ( const titleOrLink = !noLink ? (
isSheet ? ( isSheet ? (
@@ -70,10 +71,13 @@ export default function SubLink({
Comp Comp
) )
) : ( ) : (
<h4 className={cn( <h4
data-search-lvl0={level === 0 && hasActiveChild ? "true" : undefined}
className={cn(
"font-medium sm:text-sm text-foreground/90 hover:text-foreground transition-colors", "font-medium sm:text-sm text-foreground/90 hover:text-foreground transition-colors",
hasActiveChild ? "text-foreground" : "text-foreground/80" hasActiveChild ? "text-foreground" : "text-foreground/80"
)}> )}
>
{title} {title}
</h4> </h4>
); );
@@ -86,11 +90,7 @@ export default function SubLink({
<div className={cn("flex flex-col gap-1 w-full")}> <div className={cn("flex flex-col gap-1 w-full")}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}> <Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger <CollapsibleTrigger
className={cn( className="w-full pr-5 text-left cursor-pointer"
"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
)}
aria-expanded={isOpen} aria-expanded={isOpen}
aria-controls={`collapsible-${fullHref.replace(/[^a-zA-Z0-9]/g, '-')}`} aria-controls={`collapsible-${fullHref.replace(/[^a-zA-Z0-9]/g, '-')}`}
> >
@@ -108,13 +108,13 @@ export default function SubLink({
<CollapsibleContent <CollapsibleContent
id={`collapsible-${fullHref.replace(/[^a-zA-Z0-9]/g, '-')}`} id={`collapsible-${fullHref.replace(/[^a-zA-Z0-9]/g, '-')}`}
className={cn( 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" isOpen ? "animate-collapsible-down" : "animate-collapsible-up"
)} )}
> >
<div <div
className={cn( className={cn(
"flex flex-col items-start sm:text-sm text-foreground/80 ml-0.5 mt-2.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" 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"; "use client"
import TocObserver from "./toc-observer";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ListIcon } from "lucide-react";
import Sponsor from "./Sponsor";
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 }) { export default function Toc({ tocs }: { tocs: TocItem[] }) {
const tocs = await getDocsTocs(path); const { activeId, setActiveId } = useActiveSection(tocs)
return ( return (
<div className="lg:flex hidden toc flex-[1.5] min-w-[238px] py-5 sticky top-16 h-[calc(100vh-4rem)]"> <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="flex flex-col h-full w-full px-2 gap-2 mb-auto"> <div className="mb-auto flex h-full w-full flex-col gap-2 px-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ListIcon className="w-4 h-4" /> <ListIcon className="h-4 w-4" />
<h3 className="font-medium text-sm">On this page</h3> <h3 className="text-sm font-medium">On this page</h3>
</div> </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"> <ScrollArea className="h-full">
<TocObserver data={tocs} /> <TocObserver data={tocs} activeId={activeId} onActiveIdChange={setActiveId} />
</ScrollArea> </ScrollArea>
</div> </div>
<Sponsor /> <Sponsor />
</div> </div>
</div> </div>
); )
} }

View File

@@ -2,7 +2,7 @@ import { PropsWithChildren } from "react";
export function Typography({ children }: PropsWithChildren) { export function Typography({ children }: PropsWithChildren) {
return ( 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} {children}
</div> </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( className={cn(
"flex touch-none select-none transition-colors", "flex touch-none select-none transition-colors",
orientation === "vertical" && 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" && 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 className
)} )}
{...props} {...props}

View File

@@ -3,7 +3,7 @@
import * as React from "react"; import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog"; import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react"; import { PanelRightClose } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -51,7 +51,7 @@ const sheetVariants = cva(
interface SheetContentProps interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {} VariantProps<typeof sheetVariants> { }
const SheetContent = React.forwardRef< const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>, React.ElementRef<typeof SheetPrimitive.Content>,
@@ -65,8 +65,8 @@ const SheetContent = React.forwardRef<
{...props} {...props}
> >
{children} {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"> <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">
<X className="h-4 w-4" /> <PanelRightClose className="w-6 h-6 shrink-0 text-muted-foreground" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>

View File

@@ -6,10 +6,10 @@ const Table = React.forwardRef<
HTMLTableElement, HTMLTableElement,
React.HTMLAttributes<HTMLTableElement> React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto"> <div className="relative w-full overflow-auto border border-border rounded-lg">
<table <table
ref={ref} ref={ref}
className={cn("w-full caption-bottom text-sm", className)} className={cn("w-full caption-bottom text-sm !my-0", className)}
{...props} {...props}
/> />
</div> </div>
@@ -20,7 +20,7 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ 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" TableHeader.displayName = "TableHeader"

View File

@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}

View File

@@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const toggleVariants = cva( 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: { variants: {
variant: { variant: {
@@ -18,7 +18,7 @@ const toggleVariants = cva(
size: { size: {
default: "h-9 px-2 min-w-9", default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8", 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", lg: "h-10 px-2.5 min-w-10",
}, },
}, },

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,19 +0,0 @@
---
title: Appearance Settings
description: Colors and Typography.
---
Customize your brand without coding.
## Colors
We use a smart palette system.
* **Primary**: Headlines and main buttons.
* **Secondary**: Subheadings and UI elements.
* **Accent**: Highlights and links.
## Typography
Choose from GDPR-compliant, locally hosted font pairings like:
* **Modern**: Inter
* **Editorial**: Playfair Display

View File

@@ -1,17 +0,0 @@
---
title: Email Settings
description: Transactional emails and SMTP.
---
WooNooW replaces default WooCommerce emails with beautiful, responsive templates.
## Template Editor
Go to **Settings > Email** to customize:
* **Order Confirmation**
* **Shipping Updates**
* **Account Notifications**
## SMTP Configuration
Ensure your emails hit the inbox, not spam. We recommend using a dedicated SMTP service like SendGrid or Postmark.

View File

@@ -1,50 +0,0 @@
---
title: Module Integration
description: Integrating Addons usage with Module Registry
date: 2024-01-31
---
## Vision
**Module Registry as the Single Source of Truth.**
Functionality extensions—whether built-in Modules or external Addons—should live in the same UI.
## Registration
Addons register themselves via the `woonoow/addon_registry` filter.
```php
add_filter('woonoow/addon_registry', function($addons) {
$addons['my-shipping-addon'] = [
'id' => 'my-shipping-addon',
'name' => 'My Shipping',
'description' => 'Custom shipping integration',
'version' => '1.0.0',
'author' => 'My Company',
'category' => 'shipping',
'icon' => 'truck',
'settings_url' => '/settings/shipping/my-addon',
'spa_bundle' => plugin_dir_url(__FILE__) . 'dist/addon.js',
];
return $addons;
});
```
This ensures your addon appears in the **WooNooW Modules** list with a consistent UI, toggle switch, and settings link.
## Settings Pages
Addons can register their own SPA routes for settings pages.
```php
add_filter('woonoow/spa_routes', function($routes) {
$routes[] = [
'path' => '/settings/shipping/my-addon',
'component_url' => plugin_dir_url(__FILE__) . 'dist/Settings.js',
'title' => 'My Addon Settings',
];
return $routes;
});
```

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

@@ -3,9 +3,10 @@ title: Header & Footer
description: Customize your store's global navigation and footer area. description: Customize your store's global navigation and footer area.
--- ---
Your store's header and footer are crucial for navigation and branding. Use the built-in settings## Footer
Configure your footer layout and copyright text.
![Footer Preview](/images/docs/builder/footer-preview.png)
Your store's header and footer are crucial for navigation and branding. Use the built-in settings to configure them without any code.
## Header Settings ## Header Settings

View File

@@ -18,5 +18,11 @@ Showcase your services or product highlights in a clean grid layout. Supports 2,
### Content Block ### Content Block
A versatile text block with optional image. Great for "About Us" sections or storytelling. You can position the image on the left or right. A versatile text block with optional image. Great for "About Us" sections or storytelling. You can position the image on the left or right.
### Image Text Section
A modern alternating layout combining media (images or video) on one side and structured content (headings, text, buttons) on the other. You can choose whether the image appears on the left or the right. These elements have independent styling options allowing for maximum layout creativity.
### Contact Form Section
A fully functional form allowing visitors to get in touch. Includes configurable fields for Name, Email, Subject, and Message, and automatically connects to your site's contact email setting.
### Call to Action (CTA) Banner ### Call to Action (CTA) Banner
A high-converting strip designed to get clicks. Perfect for newsletter signups or limited-time offers. A high-converting strip designed to get clicks. Perfect for newsletter signups or limited-time offers.

View File

@@ -0,0 +1,36 @@
---
title: Appearance Settings
description: Colors and Typography.
---
Customize your brand without coding.
## Colors
We use a smart palette system.
* **Primary**: Headlines and main buttons.
* **Secondary**: Subheadings and UI elements.
* **Accent**: Highlights and links.
## Typography
Choose from GDPR-compliant, locally hosted font pairings like:
* **Modern**: Inter
* **Editorial**: Playfair Display
## Advanced Appearance Settings
You can fine tune how WooNooW interacts with your standard WordPress site from the **General** settings panel.
### SPA Mode
The **Single Page Application Mode** ensures that customers stay inside the fast, React-based WooNooW app during their entire shopping journey.
* **Enabled (Default):** All WooNooW page links (like shop, cart, account) will intercept standard WordPress navigation and load instantly without page refreshes.
* **Disabled:** The store will behave like a traditional WordPress site, reloading the browser on every page change.
### WordPress Admin Bar Visibility
For a cleaner frontend experience, you can hide the default black WordPress Admin Bar that normally appears at the top of the screen for logged-in administrators and staff.
* Toggle **Hide Admin Bar on Frontend** to remove it from the Customer SPA. This ensures your sticky headers and floating elements display exactly as customers will see them.
### Layout Styles
Choose how your container behaves on larger desktop monitors:
* **Full Width:** Expands your headers, footers, and page sections to cover the entire width of the browser.
* **Boxed:** Constrains the main content area to a maximum width (typically 1200px) and centers it on the screen.

View File

@@ -0,0 +1,261 @@
---
title: Email Notifications
description: Configure transactional emails and notification templates
---
WooNooW includes a modern notification system that replaces WooCommerce's default emails with beautiful, customizable templates.
Navigate to **Settings > Notifications** to manage all settings.
---
## Email System Mode
WooNooW can use its own email templates or fall back to WooCommerce defaults.
| Mode | Description |
|------|-------------|
| **WooNooW** (default) | Uses WooNooW's responsive templates with rich customization |
| **WooCommerce** | Disables WooNooW emails and uses native WC templates |
To change modes, go to **Settings > Notifications > Channels** and toggle the Email System setting.
> [!TIP]
> Use WooCommerce mode if you have heavily customized WC email templates or use a third-party email customization plugin.
---
## Notification Dashboard
The main Notifications page shows a card-based overview:
### Recipients
| Card | Description | Path |
|------|-------------|------|
| **Staff** | Notifications for admins (orders, low stock, new customers) | `/settings/notifications/staff` |
| **Customer** | Transactional emails (order updates, account, shipping) | `/settings/notifications/customer` |
### Channels
| Card | Description | Path |
|------|-------------|------|
| **Channel Configuration** | Email, Push, WhatsApp, Telegram settings | `/settings/notifications/channels` |
| **Activity Log** | View sent/failed/pending notification history | `/settings/notifications/activity-log` |
---
## Email Events
WooNooW sends notifications for the following events. Each event can have separate templates for **Staff** and **Customer**.
### Order Events
| Event | Trigger | Staff | Customer |
|-------|---------|:-----:|:--------:|
| New Order | Order placed | ✅ | ✅ |
| Order Processing | Payment received | ❌ | ✅ |
| Order Completed | Order marked complete | ❌ | ✅ |
| Order On-Hold | Order put on hold | ❌ | ✅ |
| Order Cancelled | Order cancelled | ✅ | ✅ |
| Order Refunded | Full/partial refund issued | ✅ | ✅ |
| Order Failed | Payment failed | ✅ | ❌ |
| Customer Note | Note added to order | ❌ | ✅ |
### Customer Events
| Event | Trigger | Staff | Customer |
|-------|---------|:-----:|:--------:|
| New Account | Customer registers | ✅ | ✅ |
| Password Reset | Reset link requested | ❌ | ✅ |
### Inventory Events
| Event | Trigger | Staff | Customer |
|-------|---------|:-----:|:--------:|
| Low Stock | Product reaches low stock threshold | ✅ | ❌ |
| Out of Stock | Product stock reaches 0 | ✅ | ❌ |
### Subscription Events
> [!NOTE]
> Subscription events only appear when the **Subscriptions** module is enabled.
| Event | Trigger | Staff | Customer |
|-------|---------|:-----:|:--------:|
| Subscription Created | New subscription starts | ✅ | ✅ |
| Subscription Renewed | Successful renewal payment | ❌ | ✅ |
| Subscription Pending Cancel | Customer requests cancellation | ✅ | ✅ |
| Subscription Cancelled | Subscription ended | ✅ | ✅ |
| Subscription Expired | Subscription reached end date | ❌ | ✅ |
| Subscription Paused | Customer paused subscription | ❌ | ✅ |
| Subscription Resumed | Customer resumed subscription | ❌ | ✅ |
| Renewal Failed | Payment failed | ✅ | ✅ |
| Payment Reminder | Upcoming renewal notice | ❌ | ✅ |
---
## Template Editor
Click any event to open the template editor. You can customize:
- **Enable/Disable** - Toggle the notification on or off
- **Subject Line** - Use variables like `{{order_number}}`
- **Email Body** - Rich text editor with formatting
### Staff vs Customer Templates
Each event has two template tabs:
- **Staff Template** - Sent to admin email, includes administrative details
- **Customer Template** - Sent to customer, friendly and informative
---
## Available Variables
Use these placeholders in your templates. They are replaced with actual values when the email is sent.
### Order Variables
| Variable | Description |
|----------|-------------|
| `{{order_number}}` | Order ID |
| `{{order_date}}` | Date order was placed |
| `{{order_total}}` | Total amount |
| `{{order_status}}` | Current status |
| `{{order_items}}` | List of ordered products |
| `{{shipping_method}}` | Selected shipping |
| `{{payment_method}}` | Payment method used |
### Customer Variables
| Variable | Description |
|----------|-------------|
| `{{customer_name}}` | First + last name |
| `{{customer_first_name}}` | First name only |
| `{{customer_email}}` | Email address |
| `{{billing_address}}` | Full billing address |
| `{{shipping_address}}` | Full shipping address |
### Site Variables
| Variable | Description |
|----------|-------------|
| `{{site_name}}` | Your site title |
| `{{site_url}}` | Your site URL |
| `{{admin_email}}` | Admin email address |
### Subscription Variables
| Variable | Description |
|----------|-------------|
| `{{subscription_id}}` | Subscription ID |
| `{{next_payment_date}}` | Next billing date |
| `{{subscription_total}}` | Recurring amount |
### Account Variables
| Variable | Description |
|----------|-------------|
| `{{reset_link}}` | Password reset URL |
| `{{user_login}}` | Username |
---
## Template Syntax
Email templates support **card blocks** for structured layouts and **buttons** for calls-to-action.
### Card Blocks
Wrap content in cards to create visual sections:
```
[card:type]
Your content here...
[/card]
```
#### Available Card Types
| Type | Use For | Styling |
|------|---------|---------|
| `default` | Standard content | White background |
| `hero` | Header/intro | Gradient background (uses your Appearance colors) |
| `success` | Confirmations | Light green background |
| `info` | Information | Light blue background |
| `warning` | Alerts | Light yellow background |
| `basic` | Footer/muted text | No background, no padding |
### Button Syntax
Add clickable buttons inside cards:
```
[button:solid]({{order_url}})View Your Order[/button]
[button:outline]({{shop_url}})Continue Shopping[/button]
```
| Style | Description |
|-------|-------------|
| `solid` | Filled button with primary color |
| `outline` | Border-only button |
| `link` | Plain text link |
### Example Template
```
[card:hero]
# Order Confirmed! 🎉
Thank you for your order, {{customer_first_name}}!
[/card]
[card]
## Order Details
**Order:** #{{order_number}}
**Date:** {{order_date}}
**Total:** {{order_total}}
{{order_items}}
[button:solid]({{order_url}})View Your Order[/button]
[/card]
[card:basic]
Questions? Contact us at {{support_email}}
[/card]
```
> [!TIP]
> Use Markdown formatting inside cards: `# Heading`, `**bold**`, `- lists`, etc.
---
## Email Delivery
WooNooW uses WordPress's built-in mail function (`wp_mail`).
> [!TIP]
> For reliable delivery, install **WP Mail SMTP** and connect to a transactional email service (SendGrid, Postmark, Mailgun).
---
## Troubleshooting
### Emails Not Sending
1. Check if the event is **enabled** in Settings > Notifications
2. Verify the email system mode is set to **WooNooW**
3. Check if WordPress can send emails (test with a contact form plugin)
4. Review the **Activity Log** for failed deliveries
### Emails Going to Spam
1. Use a dedicated SMTP service via WP Mail SMTP
2. Verify your domain (SPF, DKIM, DMARC records)
3. Avoid spam trigger words in subject lines
### Test Emails
Use the **Send Test** button in the template editor to preview how emails appear in customer inboxes.

View File

@@ -14,3 +14,5 @@ To receive updates and support, you must activate your license key.
## OAuth Connection ## OAuth Connection
Some features require connecting your store to our cloud. Click "Connect with WooNooW" to authorize the connection securely. Some features require connecting your store to our cloud. Click "Connect with WooNooW" to authorize the connection securely.
![License Connection](/images/docs/configuration/license-connect.png)

View File

@@ -9,6 +9,8 @@ WooNooW is built with a modular architecture. You can enable or disable specific
Navigate to **Settings > Modules** to manage these components. Navigate to **Settings > Modules** to manage these components.
![Modules List](/images/docs/configuration/modules-list.png)
## Available Modules ## Available Modules
### Marketing ### Marketing
@@ -20,9 +22,19 @@ Navigate to **Settings > Modules** to manage these components.
- **Subscriptions**: Enable recurring payments and subscription products. - **Subscriptions**: Enable recurring payments and subscription products.
- **Pre-Orders**: Allow customers to order products before they are available. - **Pre-Orders**: Allow customers to order products before they are available.
### Developer ### Sales
- **Custom CSS/JS**: Inject custom code without editing theme files. - **Licensing**: Sell software licenses with activation limits, expiration dates, and domain validation. Required for Software Distribution.
- **Beta Features**: Early access to experimental features (use with caution). - **Software Distribution**: Distribute software updates with version tracking, changelogs, and automatic update checking. Works with WordPress plugins/themes or any software type. *Requires Licensing module.*
## Module Dependencies
Some modules depend on others:
| Module | Requires |
|--------|----------|
| Software Distribution | Licensing |
If you try to enable a module without its dependencies, you'll be prompted to enable the required modules first.
## How to Enable/Disable ## How to Enable/Disable
1. Find the module card in the list. 1. Find the module card in the list.

View File

@@ -0,0 +1,243 @@
---
title: Module Registry
description: Register custom modules and addons with WooNooW's unified module system
---
WooNooW's modular architecture allows developers to create custom modules and addons that integrate seamlessly with the **Settings > Modules** UI.
## Overview
The Module Registry provides a unified system for:
- Enable/disable toggle in the admin UI
- Custom settings with schema-based forms
- Dependencies on other modules
- SPA route registration for custom settings pages
## Registering a Module
Add your module using the `woonoow/modules/registry` filter:
```php
add_filter('woonoow/modules/registry', 'my_plugin_register_module');
function my_plugin_register_module($modules) {
$modules['my-module'] = [
'id' => 'my-module',
'name' => __('My Custom Module', 'my-plugin'),
'description' => __('Description of what this module does.', 'my-plugin'),
'icon' => 'Sparkles', // Lucide icon name
'category' => 'marketing', // marketing, sales, developer, etc.
'requires' => [], // Array of required module IDs
'settings' => [], // Settings schema array
];
return $modules;
}
```
## Module Properties
| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `id` | string | Yes | Unique identifier (lowercase, hyphens) |
| `name` | string | Yes | Display name in the UI |
| `description` | string | Yes | Brief description of functionality |
| `icon` | string | No | Lucide icon name (e.g., `Package`, `Mail`) |
| `category` | string | No | Grouping category: `marketing`, `sales`, `developer` |
| `requires` | array | No | Array of module IDs this depends on |
| `settings` | array | No | Settings schema for module configuration |
---
## Settings Schema
Define a settings schema to allow users to configure your module:
```php
'settings' => [
[
'id' => 'api_key',
'type' => 'text',
'label' => __('API Key', 'my-plugin'),
'description' => __('Enter your API key', 'my-plugin'),
'default' => '',
],
[
'id' => 'rate_limit',
'type' => 'number',
'label' => __('Rate Limit', 'my-plugin'),
'description' => __('Max requests per minute', 'my-plugin'),
'default' => 10,
'min' => 1,
'max' => 100,
],
[
'id' => 'enable_debug',
'type' => 'toggle',
'label' => __('Enable Debug Mode', 'my-plugin'),
'default' => false,
],
],
```
### Available Field Types
| Type | Description | Properties |
|------|-------------|------------|
| `text` | Single-line text input | `default`, `placeholder` |
| `textarea` | Multi-line text input | `default`, `rows` |
| `number` | Numeric input with validation | `default`, `min`, `max`, `step` |
| `toggle` | Boolean on/off switch | `default` (true/false) |
| `select` | Dropdown selection | `default`, `options` (array of `{value, label}`) |
| `checkbox` | Single checkbox | `default` (true/false) |
| `color` | Color picker | `default` (#hex value) |
| `url` | URL input with validation | `default`, `placeholder` |
| `email` | Email input with validation | `default`, `placeholder` |
| `password` | Password input (masked) | `default` |
### Common Field Properties
| Property | Type | Description |
|----------|------|-------------|
| `id` | string | Unique field identifier |
| `type` | string | Field type from list above |
| `label` | string | Display label |
| `description` | string | Help text below field |
| `default` | mixed | Default value |
### Select Field Example
```php
[
'id' => 'display_mode',
'type' => 'select',
'label' => __('Display Mode', 'my-plugin'),
'default' => 'grid',
'options' => [
['value' => 'grid', 'label' => __('Grid', 'my-plugin')],
['value' => 'list', 'label' => __('List', 'my-plugin')],
['value' => 'carousel', 'label' => __('Carousel', 'my-plugin')],
],
],
```
---
## Checking Module Status
Use `ModuleRegistry::is_enabled()` to check if your module is active:
```php
use WooNooW\Core\ModuleRegistry;
if (ModuleRegistry::is_enabled('my-module')) {
// Module is enabled, initialize features
My_Module_Manager::init();
}
```
## Module Lifecycle Events
Hook into module enable/disable events:
```php
// When any module is enabled
add_action('woonoow/module/enabled', function($module_id) {
if ($module_id === 'my-module') {
// Create database tables, initialize settings, etc.
My_Module_Manager::install();
}
});
// When any module is disabled
add_action('woonoow/module/disabled', function($module_id) {
if ($module_id === 'my-module') {
// Cleanup if necessary
}
});
```
---
## SPA Routes for Settings Pages
Addons can register custom React settings pages:
```php
add_filter('woonoow/spa_routes', function($routes) {
$routes[] = [
'path' => '/settings/my-addon',
'component_url' => plugin_dir_url(__FILE__) . 'dist/Settings.js',
'title' => 'My Addon Settings',
];
return $routes;
});
```
## Addon Registry (External Addons)
External addons can also register via `woonoow/addon_registry` for extended metadata:
```php
add_filter('woonoow/addon_registry', function($addons) {
$addons['my-shipping-addon'] = [
'id' => 'my-shipping-addon',
'name' => 'My Shipping',
'description' => 'Custom shipping integration',
'version' => '1.0.0',
'author' => 'My Company',
'category' => 'shipping',
'icon' => 'truck',
'settings_url' => '/settings/shipping/my-addon',
'spa_bundle' => plugin_dir_url(__FILE__) . 'dist/addon.js',
];
return $addons;
});
```
---
## Complete Example
```php
<?php
namespace MyPlugin;
class MyModuleSettings {
public static function init() {
add_filter('woonoow/modules/registry', [__CLASS__, 'register']);
}
public static function register($modules) {
$modules['my-module'] = [
'id' => 'my-module',
'name' => __('My Module', 'my-plugin'),
'description' => __('Adds awesome features to your store.', 'my-plugin'),
'icon' => 'Zap',
'category' => 'marketing',
'requires' => [], // No dependencies
'settings' => [
[
'id' => 'feature_enabled',
'type' => 'toggle',
'label' => __('Enable Feature', 'my-plugin'),
'default' => true,
],
],
];
return $modules;
}
}
// Initialize on plugins_loaded
add_action('plugins_loaded', ['MyPlugin\MyModuleSettings', 'init']);
```
## Best Practices
1. **Check module status** before loading heavy features
2. **Use the `woonoow/module/enabled` hook** to run installation routines only when needed
3. **Specify dependencies** so WooNooW can prompt users to enable required modules
4. **Provide meaningful descriptions** to help users understand what each module does

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>

View File

@@ -0,0 +1,218 @@
---
title: Software Updates API
description: Distribute software updates with license-based access control
---
The Software Distribution module enables selling WordPress plugins, themes, or any software with automatic update checking, secure downloads, and version management.
## Prerequisites
- Enable **Licensing** module (required)
- Enable **Software Distribution** module in Settings → Modules
- Configure downloadable products with software distribution enabled
## Product Configuration
When editing a downloadable product in WooCommerce, you'll see a new "Software Distribution" section:
| Field | Description |
|-------|-------------|
| **Enable Software Updates** | Allow customers to check for updates via API |
| **Software Slug** | Unique identifier (e.g., `my-plugin`) used in API calls |
| **Current Version** | Latest version number (e.g., `1.2.3`) |
### WordPress Integration (Optional)
Enable "WordPress Plugin/Theme" to add these fields:
- **Requires WP** - Minimum WordPress version
- **Tested WP** - Tested up to WordPress version
- **Requires PHP** - Minimum PHP version
## API Endpoints
### Check for Updates
```
GET /wp-json/woonoow/v1/software/check
POST /wp-json/woonoow/v1/software/check
```
**Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `license_key` | string | Yes | Valid license key |
| `slug` | string | Yes | Software slug |
| `version` | string | Yes | Current installed version |
| `site_url` | string | No | Site URL for tracking |
**Response:**
```json
{
"success": true,
"update_available": true,
"product": {
"name": "My Plugin",
"slug": "my-plugin"
},
"current_version": "1.0.0",
"latest_version": "1.2.0",
"changelog": "## What's New\n- Added feature X\n- Fixed bug Y",
"release_date": "2026-02-01 12:00:00",
"download_url": "https://your-store.com/wp-json/woonoow/v1/software/download?token=..."
}
```
For WordPress plugins/themes, an additional `wordpress` object is included:
```json
{
"wordpress": {
"requires": "6.0",
"tested": "6.7",
"requires_php": "7.4",
"icons": { "1x": "...", "2x": "..." },
"banners": { "low": "...", "high": "..." }
}
}
```
### Download File
```
GET /wp-json/woonoow/v1/software/download?token=<token>
```
Download tokens are single-use and expire after 5 minutes.
### Get Changelog
```
GET /wp-json/woonoow/v1/software/changelog?slug=<slug>
GET /wp-json/woonoow/v1/software/changelog?slug=<slug>&version=<version>
```
Returns version history with changelogs.
## WordPress Client Integration
Include the updater class in your plugin or theme to enable automatic updates:
### 1. Copy the Updater Class
Copy `class-woonoow-updater.php` from the WooNooW plugin's `templates/updater/` directory to your plugin.
### 2. Initialize in Your Plugin
```php
<?php
// In your main plugin file
require_once plugin_dir_path(__FILE__) . 'includes/class-woonoow-updater.php';
new WooNooW_Updater([
'api_url' => 'https://your-store.com/',
'slug' => 'my-plugin',
'version' => MY_PLUGIN_VERSION,
'license_key' => get_option('my_plugin_license_key'),
'plugin_file' => __FILE__,
]);
```
### 3. For Themes
```php
<?php
// In your theme's functions.php
require_once get_theme_file_path('includes/class-woonoow-updater.php');
new WooNooW_Updater([
'api_url' => 'https://your-store.com/',
'slug' => 'my-theme',
'version' => wp_get_theme()->get('Version'),
'license_key' => get_option('my_theme_license_key'),
'theme_slug' => 'my-theme',
]);
```
## Non-WordPress Integration
For other software types, make HTTP requests directly to the API:
### JavaScript Example
```javascript
async function checkForUpdates(licenseKey, currentVersion) {
const response = await fetch('https://your-store.com/wp-json/woonoow/v1/software/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
license_key: licenseKey,
slug: 'my-software',
version: currentVersion,
}),
});
const data = await response.json();
if (data.update_available) {
console.log(`Update available: v${data.latest_version}`);
// Download from data.download_url
}
return data;
}
```
### Python Example
```python
import requests
def check_for_updates(license_key: str, current_version: str) -> dict:
response = requests.post(
'https://your-store.com/wp-json/woonoow/v1/software/check',
json={
'license_key': license_key,
'slug': 'my-software',
'version': current_version,
}
)
data = response.json()
if data.get('update_available'):
print(f"Update available: v{data['latest_version']}")
# Download from data['download_url']
return data
```
## Managing Versions
Use the Admin SPA at **Products → Software Versions** to:
- View all software-enabled products
- Release new versions with changelogs
- Track download counts per version
- Set current (latest) version
## Error Codes
| Error | Description |
|-------|-------------|
| `invalid_license` | License key is invalid or expired |
| `product_not_found` | Software slug doesn't match any product |
| `software_disabled` | Software distribution not enabled for product |
| `invalid_token` | Download token expired or already used |
| `module_disabled` | Software Distribution module is disabled |
## Security
- All API endpoints require valid license key
- Download tokens are single-use and expire in 5 minutes
- Rate limiting: 10 requests/minute per license (configurable)
- IP address logged with download tokens

View File

@@ -0,0 +1,45 @@
---
title: Store Owner Guide
description: Managing and distributing digital software products.
---
WooNooW's Software Updates module allows you to securely sell, distribute, and manage updates for your digital products, plugins, or applications.
## Enabling the Software Module
To begin distributing software, you must first enable the module:
1. Navigate to **WooNooW > Settings > Modules**.
2. Toggle on the **Software Updates** module.
3. Save changes.
## Creating a Software Product
Software products are created by extending standard WooCommerce virtual products.
1. Go to **Products > Add New** in WordPress.
2. Check both the **Virtual** and **Downloadable** checkboxes in the Product Data panel.
3. In the left tabs, select **Software Details**.
4. Configure the following specific to your software:
- **Software Type:** Identify the platform (e.g., WordPress Plugin, Desktop App).
- **Current Version:** The latest release version (e.g., 1.2.0).
- **Requires PHP/WP:** Set minimum system requirements (optional).
- **Tested Up To:** Set the maximum compatible platform version (optional).
5. Add your downloadable file under the standard **Downloadable** tab.
6. Publish the product.
## Managing Version History
When you release an update:
1. Navigate to **Store > Software Versions** in your WooNooW Admin SPA.
2. Click **Create Release**.
3. Select the software product you are updating.
4. Enter the new **Version Number** (e.g., 1.3.0).
5. Provide a detailed **Changelog**. Use Markdown to list features, fixes, and notes.
6. Publish the release.
When customers check for updates within their application (using the API), the system will serve them the latest version and display this changelog.
## Licensing
You can choose to attach License Keys to your software to prevent unauthorized use.
1. Enable the **Licensing** module in Settings.
2. When configuring your Software Product, check the **Requires License** box.
3. Define the activation limit (e.g., 1 site, 5 sites, or unlimited).
When a customer purchases the software, the system will automatically generate a unique license key and deliver it in the checkout confirmation and email receipt. Customers can manage their active activations from their **My Account > Licenses** dashboard.

View File

@@ -62,11 +62,18 @@ After reviewing everything:
## Features ## Features
### Guest Checkout ### Guest Checkout & Auto-Registration
Allow customers to checkout without creating an account. Allow customers to checkout without creating an account upfront.
Configure in **WooCommerce → Settings → Accounts & Privacy**. Configure in **WooCommerce → Settings → Accounts & Privacy**.
**Seamless Auto-Registration:**
WooNooW implements a frictionless auto-registration flow. If a guest checks out with an email that isn't registered, the system will:
1. Automatically create an account in the background.
2. Securely generate a random password.
3. Automatically log the user in immediately after checkout.
4. Send an email containing their new login credentials so they can track their order and easily return.
### Coupon Codes ### Coupon Codes
Customers can apply discount codes: Customers can apply discount codes:

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

Some files were not shown because too many files have changed in this diff Show More