Compare commits
16 Commits
162d5384e2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf2ef37f49 | ||
|
|
9a2b5f57ed | ||
|
|
9a0886817a | ||
|
|
88add102be | ||
|
|
4c4b604814 | ||
|
|
f714a0a942 | ||
|
|
c7354aee85 | ||
|
|
ab755844a3 | ||
|
|
aac81dff8a | ||
|
|
8789de2e2c | ||
|
|
b7fbcef6b1 | ||
|
|
a1055d3f22 | ||
|
|
f58de663a3 | ||
|
|
85efc218c6 | ||
|
|
d3e89f1a42 | ||
|
|
aa33e15604 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile
|
||||||
|
README.md
|
||||||
|
.env*
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
77
Dockerfile
Normal 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"]
|
||||||
@@ -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,53 +61,49 @@ 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">
|
||||||
<DocsBreadcrumb paths={slug} />
|
<MobToc tocs={tocs} title={title} />
|
||||||
<Typography>
|
<DocsBreadcrumb paths={slug} />
|
||||||
<h1 className="text-3xl !-mt-0.5">{title}</h1>
|
<Typography>
|
||||||
<p className="-mt-4 text-muted-foreground text-[16.5px]">{description}</p>
|
<h1 className="-mt-0.5! text-3xl">{title}</h1>
|
||||||
<div>{res.content}</div>
|
<p className="text-muted-foreground -mt-4 text-[16.5px]">{description}</p>
|
||||||
<div
|
<div>{res.content}</div>
|
||||||
className={`my-8 flex items-center border-b-2 border-dashed border-x-muted-foreground ${
|
<div
|
||||||
docuConfig.repository?.editLink ? "justify-between" : "justify-end"
|
className={`border-x-muted-foreground my-8 flex items-center border-b-2 border-dashed ${docuConfig.repository?.editLink ? "justify-between" : "justify-end"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{docuConfig.repository?.editLink && <EditThisPage filePath={filePath} />}
|
{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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Pagination pathname={pathName} />
|
<Pagination pathname={pathName} />
|
||||||
</Typography>
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<Toc tocs={tocs} />
|
||||||
</div>
|
</div>
|
||||||
<Toc path={pathName} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +8,15 @@ 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">
|
||||||
<Leftbar key="leftbar" />
|
<div className="flex flex-1 items-start w-full">
|
||||||
<div className="flex-[5.25] px-1">
|
<Leftbar key="leftbar" />
|
||||||
{children}
|
<main className="flex-1 min-w-0 dark:bg-background/50 min-h-screen flex flex-col">
|
||||||
|
<DocsNavbar />
|
||||||
|
<div className="flex-1 w-full">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" />
|
||||||
{children}
|
<main id="main-content" className="sm:container mx-auto w-[90vw] h-auto scroll-smooth">
|
||||||
</main>
|
{children}
|
||||||
<Footer />
|
</main>
|
||||||
<Toaster position="top-center" />
|
<Footer id="main-footer" />
|
||||||
|
</SearchProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
16
app/page.tsx
16
app/page.tsx
@@ -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" />
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
@@ -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
63
components/DocsMenu.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ROUTES, EachRoute } from "@/lib/routes";
|
||||||
|
import SubLink from "./sublink";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface DocsMenuProps {
|
||||||
|
isSheet?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current context from the path
|
||||||
|
function getCurrentContext(path: string): string | undefined {
|
||||||
|
if (!path.startsWith('/docs')) return undefined;
|
||||||
|
|
||||||
|
// Extract the first segment after /docs/
|
||||||
|
const match = path.match(/^\/docs\/([^/]+)/);
|
||||||
|
return match ? match[1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the route that matches the current context
|
||||||
|
function getContextRoute(contextPath: string): EachRoute | undefined {
|
||||||
|
return ROUTES.find(route => {
|
||||||
|
const normalizedHref = route.href.replace(/^\/+|\/+$/g, '');
|
||||||
|
return normalizedHref === contextPath;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocsMenu({ isSheet = false, className = "" }: DocsMenuProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Skip rendering if not on a docs page
|
||||||
|
if (!pathname.startsWith("/docs")) return null;
|
||||||
|
|
||||||
|
// Get the current context
|
||||||
|
const currentContext = getCurrentContext(pathname);
|
||||||
|
|
||||||
|
// Get the route for the current context
|
||||||
|
const contextRoute = currentContext ? getContextRoute(currentContext) : undefined;
|
||||||
|
|
||||||
|
// If no context route is found, don't render anything
|
||||||
|
if (!contextRoute) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Documentation navigation"
|
||||||
|
className={cn("transition-all duration-200", className)}
|
||||||
|
>
|
||||||
|
<ul className="flex flex-col gap-1.5 py-4">
|
||||||
|
{/* Display only the items from the current context */}
|
||||||
|
<li key={contextRoute.title}>
|
||||||
|
<SubLink
|
||||||
|
{...contextRoute}
|
||||||
|
href={`/docs${contextRoute.href}`}
|
||||||
|
level={0}
|
||||||
|
isSheet={isSheet}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
components/DocsNavbar.tsx
Normal file
44
components/DocsNavbar.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowUpRight } from "lucide-react";
|
||||||
|
import Anchor from "@/components/anchor";
|
||||||
|
import docuConfig from "@/docu.json";
|
||||||
|
|
||||||
|
interface NavbarItem {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { navbar } = docuConfig;
|
||||||
|
|
||||||
|
export function DocsNavbar() {
|
||||||
|
// Show all nav items
|
||||||
|
const navItems = navbar?.menu || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hidden lg:flex items-center justify-end gap-6 h-14 px-8 mt-2">
|
||||||
|
{/* Navigation Links */}
|
||||||
|
<div className="flex items-center gap-6 text-sm font-medium text-foreground/80">
|
||||||
|
{navItems.map((item: NavbarItem) => {
|
||||||
|
const isExternal = item.href.startsWith("http");
|
||||||
|
return (
|
||||||
|
<Anchor
|
||||||
|
key={`${item.title}-${item.href}`}
|
||||||
|
href={item.href}
|
||||||
|
absolute
|
||||||
|
activeClassName="text-primary dark:text-accent md:font-semibold font-medium"
|
||||||
|
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||||
|
target={isExternal ? "_blank" : undefined}
|
||||||
|
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
{isExternal && <ArrowUpRight className="w-3.5 h-3.5" />}
|
||||||
|
</Anchor>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DocsNavbar;
|
||||||
205
components/DocsSidebar.tsx
Normal file
205
components/DocsSidebar.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ChevronDown, ChevronUp, PanelRight, MoreVertical } from "lucide-react"
|
||||||
|
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTrigger } from "@/components/ui/sheet"
|
||||||
|
import DocsMenu from "@/components/DocsMenu"
|
||||||
|
import { ModeToggle } from "@/components/ThemeToggle"
|
||||||
|
import { DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||||
|
import ContextPopover from "@/components/ContextPopover"
|
||||||
|
import TocObserver from "./TocObserver"
|
||||||
|
import * as React from "react"
|
||||||
|
import { useRef, useMemo } from "react"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import { Button } from "./ui/button"
|
||||||
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
|
import { useActiveSection } from "@/hooks"
|
||||||
|
import { TocItem } from "@/lib/toc"
|
||||||
|
import Search from "@/components/SearchBox"
|
||||||
|
import { NavMenu } from "@/components/navbar"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
|
||||||
|
interface MobTocProps {
|
||||||
|
tocs: TocItem[]
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const useClickOutside = (ref: React.RefObject<HTMLElement | null>, callback: () => void) => {
|
||||||
|
const handleClick = React.useCallback(
|
||||||
|
(event: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ref, callback]
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
document.addEventListener("mousedown", handleClick)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClick)
|
||||||
|
}
|
||||||
|
}, [handleClick])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobToc({ tocs, title }: MobTocProps) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const [isExpanded, setIsExpanded] = React.useState(false)
|
||||||
|
const tocRef = useRef<HTMLDivElement>(null)
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Use custom hooks
|
||||||
|
const { activeId, setActiveId } = useActiveSection(tocs)
|
||||||
|
|
||||||
|
// Only show on /docs pages
|
||||||
|
const isDocsPage = useMemo(() => pathname?.startsWith("/docs"), [pathname])
|
||||||
|
|
||||||
|
// Get title from active section if available, otherwise document title
|
||||||
|
const activeSection = useMemo(() => {
|
||||||
|
return tocs.find((toc) => toc.href.slice(1) === activeId)
|
||||||
|
}, [tocs, activeId])
|
||||||
|
|
||||||
|
const displayTitle = activeSection?.text || title || "On this page"
|
||||||
|
|
||||||
|
const [mounted, setMounted] = React.useState(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Toggle expanded state
|
||||||
|
const toggleExpanded = React.useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsExpanded((prev) => !prev)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Close TOC when clicking outside
|
||||||
|
useClickOutside(tocRef, () => {
|
||||||
|
if (isExpanded) {
|
||||||
|
setIsExpanded(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle body overflow when TOC is expanded
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isExpanded) {
|
||||||
|
document.body.style.overflow = "hidden"
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = ""
|
||||||
|
}
|
||||||
|
}, [isExpanded])
|
||||||
|
|
||||||
|
// Don't render anything if not on docs page
|
||||||
|
if (!isDocsPage || !mounted) return null
|
||||||
|
|
||||||
|
const chevronIcon = isExpanded ? (
|
||||||
|
<ChevronUp className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
ref={tocRef}
|
||||||
|
className="sticky top-0 z-50 -mx-4 -mt-4 mb-4 lg:hidden"
|
||||||
|
initial={{ y: -100, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: -100, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<div className="bg-background/95 border-muted dark:border-foreground/10 dark:bg-background w-full border-b shadow-sm backdrop-blur-sm">
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
aria-label="Navigation menu"
|
||||||
|
>
|
||||||
|
<MoreVertical className="text-muted-foreground h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
className="flex min-w-[160px] flex-col gap-1 p-2"
|
||||||
|
>
|
||||||
|
<NavMenu />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-mx-1 h-auto flex-1 justify-between rounded-md px-2 py-2 hover:bg-transparent hover:text-inherit"
|
||||||
|
onClick={toggleExpanded}
|
||||||
|
aria-label={isExpanded ? "Collapse table of contents" : "Expand table of contents"}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium capitalize">{displayTitle}</span>
|
||||||
|
</div>
|
||||||
|
{chevronIcon}
|
||||||
|
</Button>
|
||||||
|
<Search />
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="hidden max-lg:flex">
|
||||||
|
<PanelRight className="text-muted-foreground h-6 w-6 shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent className="flex w-full flex-col gap-4 px-0 lg:w-auto" side="right">
|
||||||
|
<DialogTitle className="sr-only">Navigation Menu</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
Main navigation menu with links to different sections
|
||||||
|
</DialogDescription>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetClose className="px-4" asChild>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="mr-8">
|
||||||
|
<ModeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetClose>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex flex-col gap-4 overflow-y-auto">
|
||||||
|
<div className="mx-2 space-y-2 px-5">
|
||||||
|
<ContextPopover />
|
||||||
|
<DocsMenu isSheet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
ref={contentRef}
|
||||||
|
className="-mx-1 mt-2 max-h-[60vh] overflow-y-auto px-1 pb-2"
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
{tocs?.length ? (
|
||||||
|
<TocObserver data={tocs} activeId={activeId} onActiveIdChange={setActiveId} />
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground py-2 text-sm">No headings</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
components/Github.tsx
Normal file
26
components/Github.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import docuConfig from "@/docu.json";
|
||||||
|
|
||||||
|
export default function GitHubButton() {
|
||||||
|
const { repository } = docuConfig;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={repository.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center rounded-full p-1 text-sm font-medium text-muted-foreground border no-underline hover:bg-muted/50 transition-colors"
|
||||||
|
aria-label="View on GitHub"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
height="16"
|
||||||
|
width="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="fill-current"
|
||||||
|
>
|
||||||
|
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38v-1.32c-2.22.48-2.69-1.07-2.69-1.07-.36-.92-.89-1.17-.89-1.17-.73-.5.06-.49.06-.49.81.06 1.23.83 1.23.83.72 1.23 1.89.88 2.35.67.07-.52.28-.88.5-1.08-1.77-.2-3.64-.88-3.64-3.93 0-.87.31-1.58.82-2.14-.08-.2-.36-1.01.08-2.12 0 0 .67-.21 2.2.82a7.7 7.7 0 012.01-.27 7.7 7.7 0 012.01.27c1.53-1.03 2.2-.82 2.2-.82.44 1.11.16 1.92.08 2.12.51.56.82 1.27.82 2.14 0 3.06-1.87 3.73-3.65 3.93.29.25.54.73.54 1.48v2.2c0 .21.15.46.55.38A8 8 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
const GitHubStarButton: React.FC = () => {
|
|
||||||
const [stars, setStars] = useState<number | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('https://api.github.com/repos/gitfromwildan/docubook')
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (data.stargazers_count !== undefined) {
|
|
||||||
setStars(data.stargazers_count);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => console.error('Failed to fetch stars:', error));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const formatStars = (count: number) =>
|
|
||||||
count >= 1000 ? `${(count / 1000).toFixed(1)}K` : `${count}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href="https://github.com/gitfromwildan/docubook"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center rounded-full px-3 py-1.5 text-sm font-medium text-muted-foreground border no-underline"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
height="16"
|
|
||||||
width="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
aria-hidden="true"
|
|
||||||
className="fill-current mr-1.5"
|
|
||||||
>
|
|
||||||
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38v-1.32c-2.22.48-2.69-1.07-2.69-1.07-.36-.92-.89-1.17-.89-1.17-.73-.5.06-.49.06-.49.81.06 1.23.83 1.23.83.72 1.23 1.89.88 2.35.67.07-.52.28-.88.5-1.08-1.77-.2-3.64-.88-3.64-3.93 0-.87.31-1.58.82-2.14-.08-.2-.36-1.01.08-2.12 0 0 .67-.21 2.2.82a7.7 7.7 0 012.01-.27 7.7 7.7 0 012.01.27c1.53-1.03 2.2-.82 2.2-.82.44 1.11.16 1.92.08 2.12.51.56.82 1.27.82 2.14 0 3.06-1.87 3.73-3.65 3.93.29.25.54.73.54 1.48v2.2c0 .21.15.46.55.38A8 8 0 0016 8c0-4.42-3.58-8-8-8z" />
|
|
||||||
</svg>
|
|
||||||
{stars !== null ? formatStars(stars) : '...'}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default GitHubStarButton;
|
|
||||||
@@ -19,10 +19,14 @@ export function ScrollToTop({
|
|||||||
const [isVisible, setIsVisible] = useState(false);
|
const [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
39
components/SearchBox.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Dialog } from "@/components/ui/dialog"
|
||||||
|
import { SearchTrigger } from "@/components/SearchTrigger"
|
||||||
|
import { SearchModal } from "@/components/SearchModal"
|
||||||
|
import AlgoliaSearch from "@/components/DocSearch"
|
||||||
|
import { useSearch } from "./SearchContext"
|
||||||
|
import { DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
import { searchConfig } from "@/lib/search/config"
|
||||||
|
|
||||||
|
interface SearchProps {
|
||||||
|
/**
|
||||||
|
* Override the search type from config.
|
||||||
|
* If not provided, uses the config value.
|
||||||
|
*/
|
||||||
|
type?: "default" | "algolia"
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Search({ type, className }: SearchProps) {
|
||||||
|
const { isOpen, setIsOpen } = useSearch()
|
||||||
|
const searchType = type ?? searchConfig.type
|
||||||
|
|
||||||
|
if (searchType === "algolia") {
|
||||||
|
return <AlgoliaSearch className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logic for 'default' search
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<SearchTrigger className={className} />
|
||||||
|
</DialogTrigger>
|
||||||
|
<SearchModal isOpen={isOpen} setIsOpen={setIsOpen} />
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
components/SearchContext.tsx
Normal file
47
components/SearchContext.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
interface SearchContextType {
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: (open: boolean) => void;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchContext = createContext<SearchContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function SearchProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setIsOpen((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
|
||||||
|
event.preventDefault();
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [toggle]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchContext.Provider value={{ isOpen, setIsOpen, toggle }}>
|
||||||
|
{children}
|
||||||
|
</SearchContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSearch() {
|
||||||
|
const context = useContext(SearchContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSearch must be used within a SearchProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client"
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { 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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
197
components/TocObserver.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import clsx from "clsx"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useState, useRef, useEffect, useCallback } from "react"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import { ScrollToTop } from "./ScrollToTop"
|
||||||
|
import { TocItem } from "@/lib/toc"
|
||||||
|
|
||||||
|
interface TocObserverProps {
|
||||||
|
data: TocItem[]
|
||||||
|
activeId?: string | null
|
||||||
|
onActiveIdChange?: (id: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TocObserver({
|
||||||
|
data,
|
||||||
|
activeId: externalActiveId,
|
||||||
|
onActiveIdChange,
|
||||||
|
}: TocObserverProps) {
|
||||||
|
const itemRefs = useRef<Map<string, HTMLAnchorElement>>(new Map())
|
||||||
|
|
||||||
|
const activeId = externalActiveId ?? null
|
||||||
|
|
||||||
|
const handleLinkClick = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
onActiveIdChange?.(id)
|
||||||
|
},
|
||||||
|
[onActiveIdChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Function to check if an item has children
|
||||||
|
const hasChildren = (currentId: string, currentLevel: number) => {
|
||||||
|
const currentIndex = data.findIndex((item) => item.href.slice(1) === currentId)
|
||||||
|
if (currentIndex === -1 || currentIndex === data.length - 1) return false
|
||||||
|
|
||||||
|
const nextItem = data[currentIndex + 1]
|
||||||
|
return nextItem.level > currentLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate scroll progress for the active section
|
||||||
|
const [scrollProgress, setScrollProgress] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!activeId) return
|
||||||
|
|
||||||
|
const activeElement = document.getElementById(activeId)
|
||||||
|
if (!activeElement) return
|
||||||
|
|
||||||
|
const rect = activeElement.getBoundingClientRect()
|
||||||
|
const windowHeight = window.innerHeight
|
||||||
|
const elementTop = rect.top
|
||||||
|
const elementHeight = rect.height
|
||||||
|
|
||||||
|
// Calculate how much of the element is visible
|
||||||
|
let progress = 0
|
||||||
|
if (elementTop < windowHeight) {
|
||||||
|
progress = Math.min(1, (windowHeight - elementTop) / (windowHeight + elementHeight))
|
||||||
|
}
|
||||||
|
|
||||||
|
setScrollProgress(progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById("scroll-container") || window
|
||||||
|
|
||||||
|
container.addEventListener("scroll", handleScroll, { passive: true })
|
||||||
|
|
||||||
|
// Initial calculation
|
||||||
|
handleScroll()
|
||||||
|
|
||||||
|
return () => container.removeEventListener("scroll", handleScroll)
|
||||||
|
}, [activeId])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="text-foreground/70 hover:text-foreground relative text-sm transition-colors">
|
||||||
|
<div className="flex flex-col gap-0">
|
||||||
|
{data.map(({ href, level, text }, index) => {
|
||||||
|
const id = href.slice(1)
|
||||||
|
const isActive = activeId === id
|
||||||
|
const indent = level > 1 ? (level - 1) * 20 : 0
|
||||||
|
// Prefix with underscore to indicate intentionally unused
|
||||||
|
const _isParent = hasChildren(id, level)
|
||||||
|
const _isLastInLevel = index === data.length - 1 || data[index + 1].level <= level
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={href} className="relative">
|
||||||
|
{/* Simple L-shaped connector */}
|
||||||
|
{level > 1 && (
|
||||||
|
<div
|
||||||
|
className={clsx("absolute top-0 h-full w-6", {
|
||||||
|
"left-[6px]": indent === 20, // Level 2
|
||||||
|
"left-[22px]": indent === 40, // Level 3
|
||||||
|
"left-[38px]": indent === 60, // Level 4
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* Vertical line */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"absolute left-0 top-0 h-full w-px",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/20 dark:bg-primary/30"
|
||||||
|
: "bg-border/50 dark:bg-border/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
className="bg-primary absolute left-0 top-0 h-full w-full origin-top"
|
||||||
|
initial={{ scaleY: 0 }}
|
||||||
|
animate={{ scaleY: scrollProgress }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Horizontal line */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"absolute left-0 top-1/2 h-px w-6",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/20 dark:bg-primary/30"
|
||||||
|
: "bg-border/50 dark:bg-border/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
className="bg-primary dark:bg-accent absolute left-0 top-0 h-full w-full origin-left"
|
||||||
|
initial={{ scaleX: 0 }}
|
||||||
|
animate={{ scaleX: scrollProgress }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
onClick={() => handleLinkClick(id)}
|
||||||
|
className={clsx("relative flex items-center py-2 transition-colors", {
|
||||||
|
"text-primary dark:text-primary font-medium": isActive,
|
||||||
|
"text-muted-foreground hover:text-foreground dark:hover:text-foreground/90":
|
||||||
|
!isActive,
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
paddingLeft: `${indent}px`,
|
||||||
|
marginLeft: level > 1 ? "12px" : "0",
|
||||||
|
}}
|
||||||
|
ref={(el) => {
|
||||||
|
const map = itemRefs.current
|
||||||
|
if (el) {
|
||||||
|
map.set(id, el)
|
||||||
|
} else {
|
||||||
|
map.delete(id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Circle indicator */}
|
||||||
|
<div className="relative flex h-4 w-4 shrink-0 items-center justify-center">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"relative z-10 h-1.5 w-1.5 rounded-full transition-all duration-300",
|
||||||
|
{
|
||||||
|
"bg-primary dark:bg-primary/90 scale-100": isActive,
|
||||||
|
"bg-muted-foreground/30 dark:bg-muted-foreground/30 group-hover:bg-primary/50 dark:group-hover:bg-primary/50 scale-75 group-hover:scale-100":
|
||||||
|
!isActive,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
className="bg-primary/20 dark:bg-primary/30 absolute inset-0 rounded-full"
|
||||||
|
initial={{ scale: 1 }}
|
||||||
|
animate={{ scale: 1.8 }}
|
||||||
|
transition={{
|
||||||
|
duration: 2,
|
||||||
|
repeat: Infinity,
|
||||||
|
repeatType: "reverse",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="truncate text-sm">{text}</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Add scroll to top link at the bottom of TOC */}
|
||||||
|
<ScrollToTop className="mt-6" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { createContext } from 'react';
|
|
||||||
|
|
||||||
// Create a context to check if a component is inside an accordion group
|
|
||||||
export const AccordionGroupContext = createContext<{ inGroup: boolean } | null>(null);
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ROUTES, EachRoute } from "@/lib/routes-config";
|
|
||||||
import SubLink from "./sublink";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface DocsMenuProps {
|
|
||||||
isSheet?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DocsMenu({ isSheet = false, className = "" }: DocsMenuProps) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
if (!pathname.startsWith("/docs")) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
aria-label="Documentation navigation"
|
|
||||||
className={cn("transition-all duration-200", className)}
|
|
||||||
>
|
|
||||||
<ul className="flex flex-col gap-1.5 py-4">
|
|
||||||
{ROUTES.map((route, index) => (
|
|
||||||
<li key={route.title + index}>
|
|
||||||
<SubLink
|
|
||||||
{...route}
|
|
||||||
href={`${route.href}`}
|
|
||||||
level={0}
|
|
||||||
isSheet={isSheet}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link";
|
import 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,21 +20,25 @@ 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">
|
||||||
Copyright © {new Date().getFullYear()} {footer.copyright} - <MadeWith />
|
Copyright © {new Date().getFullYear()} {footer.copyright} - <MadeWith />
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-center lg:justify-start gap-6 mt-2 w-full">
|
<div className="flex items-center justify-center lg:justify-start gap-6 mt-2 w-full">
|
||||||
<FooterButtons />
|
<FooterButtons />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden lg:flex items-center justify-end lg:w-2/5">
|
<div className="hidden lg:flex items-center justify-end lg:w-2/5">
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -79,9 +83,9 @@ export function MadeWith() {
|
|||||||
<span className="text-muted-foreground">Made with </span>
|
<span className="text-muted-foreground">Made with </span>
|
||||||
<span className="text-primary">
|
<span className="text-primary">
|
||||||
<Link href="https://www.docubook.pro" target="_blank" rel="noopener noreferrer" className="underline underline-offset-2 text-muted-foreground">
|
<Link href="https://www.docubook.pro" target="_blank" rel="noopener noreferrer" className="underline underline-offset-2 text-muted-foreground">
|
||||||
DocuBook
|
DocuBook
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 className="space-y-2">
|
</div>
|
||||||
<ContextPopover />
|
|
||||||
<DocsMenu />
|
{/* Scrollable Navigation */}
|
||||||
</div>
|
<ScrollArea className="flex-1 px-4">
|
||||||
)}
|
<div className="space-y-2">
|
||||||
|
<ContextPopover />
|
||||||
|
<DocsMenu />
|
||||||
|
</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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
21
components/markdown/AccordionContext.tsx
Normal file
21
components/markdown/AccordionContext.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { createContext, useState, useId } from "react"
|
||||||
|
|
||||||
|
type AccordionGroupContextType = {
|
||||||
|
inGroup: boolean
|
||||||
|
groupId: string
|
||||||
|
openTitle: string | null
|
||||||
|
setOpenTitle: (title: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccordionGroupContext = createContext<AccordionGroupContextType | null>(null)
|
||||||
|
|
||||||
|
export function AccordionGroupProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [openTitle, setOpenTitle] = useState<string | null>(null)
|
||||||
|
const groupId = useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccordionGroupContext.Provider value={{ inGroup: true, groupId, openTitle, setOpenTitle }}>
|
||||||
|
{children}
|
||||||
|
</AccordionGroupContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,31 +1,20 @@
|
|||||||
"use client"
|
"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
|
||||||
|
|||||||
@@ -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, setIsOpen] = useState(defaultOpen);
|
|
||||||
const Icon = icon ? (Icons[icon] as React.FC<{ className?: string }>) : null;
|
|
||||||
|
|
||||||
// The main wrapper div for the accordion.
|
const isOpen = isInGroup ? groupOpen : localOpen
|
||||||
// All styling logic for the accordion container is handled here.
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
// Style for STANDALONE: full card with border & shadow
|
|
||||||
!isInGroup && "border rounded-lg shadow-sm",
|
|
||||||
// Style for IN GROUP: only a bottom border separator
|
|
||||||
isInGroup && "border-b last:border-b-0 border-border"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="flex items-center gap-2 w-full px-4 h-12 transition-colors bg-muted/40 dark:bg-muted/20 hover:bg-muted/70 dark:hover:bg-muted/70"
|
|
||||||
>
|
|
||||||
<ChevronRight
|
|
||||||
className={cn(
|
|
||||||
"w-4 h-4 text-muted-foreground transition-transform duration-200 flex-shrink-0",
|
|
||||||
isOpen && "rotate-90"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{Icon && <Icon className="text-foreground w-4 h-4 flex-shrink-0" />}
|
|
||||||
<h3 className="font-medium text-base text-foreground !m-0 leading-none">{title}</h3>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen && (
|
const handleToggle = () => {
|
||||||
<div className="px-4 py-3 dark:bg-muted/10 bg-muted/15">
|
if (isInGroup && setGroupOpen) {
|
||||||
{children}
|
setGroupOpen(groupOpen ? null : title)
|
||||||
</div>
|
} else {
|
||||||
)}
|
setLocalOpen(!localOpen)
|
||||||
</div>
|
}
|
||||||
);
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export default Accordion;
|
const Icon = icon ? (Icons[icon] as React.FC<{ className?: string }>) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
!isInGroup && "rounded-lg border shadow-sm",
|
||||||
|
isInGroup && "border-border border-b last:border-b-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleToggle}
|
||||||
|
className="bg-muted/40 dark:bg-muted/20 hover:bg-muted/70 dark:hover:bg-muted/70 flex w-full cursor-pointer items-center gap-2 px-4 py-3 text-start transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200",
|
||||||
|
isOpen && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{Icon && <Icon className="text-foreground h-4 w-4 shrink-0" />}
|
||||||
|
<h3 className="text-foreground m-0! text-base font-medium">{title}</h3>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && <div className="dark:bg-muted/10 bg-muted/15 px-4 py-3">{children}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Accordion
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ const Card: React.FC<CardProps> = ({ title, icon, href, horizontal, children, cl
|
|||||||
"bg-card text-card-foreground border-border",
|
"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>
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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'}
|
||||||
`} />
|
`} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,29 +1,129 @@
|
|||||||
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"];
|
||||||
|
|
||||||
type ImageProps = Omit<ComponentProps<"img">, "src"> & {
|
type ImageProps = Omit<ComponentProps<"img">, "src"> & {
|
||||||
src?: ComponentProps<typeof NextImage>["src"];
|
src?: ComponentProps<typeof NextImage>["src"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Image({
|
export default function Image({
|
||||||
src,
|
src,
|
||||||
alt = "alt",
|
alt = "alt",
|
||||||
width = 800,
|
width = 800,
|
||||||
height = 350,
|
height = 350,
|
||||||
...props
|
...props
|
||||||
}: ImageProps) {
|
}: ImageProps) {
|
||||||
if (!src) return null;
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
return (
|
|
||||||
<NextImage
|
// Lock scroll when open
|
||||||
src={src}
|
useEffect(() => {
|
||||||
alt={alt}
|
if (isOpen) {
|
||||||
width={width as Width}
|
document.body.style.overflow = "hidden";
|
||||||
height={height as Height}
|
// Check for Escape key
|
||||||
quality={40}
|
const handleEsc = (e: KeyboardEvent) => {
|
||||||
{...props}
|
if (e.key === "Escape") setIsOpen(false);
|
||||||
/>
|
};
|
||||||
);
|
window.addEventListener("keydown", handleEsc);
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "auto";
|
||||||
|
window.removeEventListener("keydown", handleEsc);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!src) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="relative group cursor-zoom-in my-6 w-full flex justify-center rounded-lg"
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
aria-label="Zoom image"
|
||||||
|
>
|
||||||
|
<span className="absolute inset-0 bg-black/0 group-hover:bg-black/5 transition-colors z-10 flex items-center justify-center opacity-0 group-hover:opacity-100 rounded-lg">
|
||||||
|
<ZoomIn className="w-8 h-8 text-white drop-shadow-md" />
|
||||||
|
</span>
|
||||||
|
<NextImage
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={width as Width}
|
||||||
|
height={height as Height}
|
||||||
|
quality={85}
|
||||||
|
className="w-full h-auto rounded-lg transition-transform duration-300 group-hover:scale-[1.01]"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<Portal>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-99999 flex items-center justify-center bg-black/90 backdrop-blur-md p-4 md:p-10 cursor-zoom-out"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
{/* Close Button */}
|
||||||
|
<button
|
||||||
|
className="absolute top-4 right-4 z-50 p-2 text-white/70 hover:text-white bg-black/20 hover:bg-white/10 rounded-full transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Image Container */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||||
|
className="relative max-w-7xl w-full h-full flex items-center justify-center"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="relative w-full h-full flex items-center justify-center" onClick={() => setIsOpen(false)}>
|
||||||
|
<NextImage
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
className="object-contain max-h-[90vh] w-auto h-auto rounded-md shadow-2xl"
|
||||||
|
quality={95}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Caption */}
|
||||||
|
{alt && alt !== "alt" && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 20, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
className="absolute bottom-6 left-1/2 -translate-x-1/2 bg-black/60 text-white px-4 py-2 rounded-full text-sm font-medium backdrop-blur-md border border-white/10"
|
||||||
|
>
|
||||||
|
{alt}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</motion.div>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Portal = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return createPortal(children, document.body);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,52 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { 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">
|
||||||
|
{title}
|
||||||
|
</h5>
|
||||||
|
<div className="text-sm [&_p]:leading-relaxed opacity-90">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
v{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"
|
||||||
</div>
|
>
|
||||||
{date && (
|
v{version}
|
||||||
<div className="text-muted-foreground text-sm">
|
</div>
|
||||||
|
{date && (
|
||||||
|
<div className="flex items-center gap-3 text-sm font-medium text-muted-foreground">
|
||||||
|
<span className="h-1 w-1 rounded-full bg-muted-foreground/30"></span>
|
||||||
|
<time dateTime={date}>
|
||||||
{new Date(date).toLocaleDateString('en-US', {
|
{new Date(date).toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
})}
|
})}
|
||||||
</div>
|
</time>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
<h2 className="text-2xl font-bold text-foreground/90 mb-3">
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h3 className="text-2xl font-bold text-foreground/90 mb-6 mt-0!">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { List, ChevronDown, ChevronUp } from "lucide-react";
|
|
||||||
import TocObserver from "./toc-observer";
|
|
||||||
import * as React from "react";
|
|
||||||
import { useRef, useMemo } from "react";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { useActiveSection } from "@/hooks";
|
|
||||||
import { TocItem } from "@/lib/toc";
|
|
||||||
|
|
||||||
interface MobTocProps {
|
|
||||||
tocs: TocItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const useClickOutside = (ref: React.RefObject<HTMLElement | null>, callback: () => void) => {
|
|
||||||
const handleClick = React.useCallback((event: MouseEvent) => {
|
|
||||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
}, [ref, callback]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
document.addEventListener('mousedown', handleClick);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClick);
|
|
||||||
};
|
|
||||||
}, [handleClick]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function MobToc({ tocs }: MobTocProps) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
|
||||||
const tocRef = useRef<HTMLDivElement>(null);
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Use custom hooks
|
|
||||||
const { activeId, setActiveId } = useActiveSection(tocs);
|
|
||||||
|
|
||||||
// Only show on /docs pages
|
|
||||||
const isDocsPage = useMemo(() => pathname?.startsWith('/docs'), [pathname]);
|
|
||||||
|
|
||||||
// Toggle expanded state
|
|
||||||
const toggleExpanded = React.useCallback((e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsExpanded(prev => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Close TOC when clicking outside
|
|
||||||
useClickOutside(tocRef, () => {
|
|
||||||
if (isExpanded) {
|
|
||||||
setIsExpanded(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle body overflow when TOC is expanded
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (isExpanded) {
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
} else {
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
};
|
|
||||||
}, [isExpanded]);
|
|
||||||
|
|
||||||
// Don't render anything if not on docs page or no TOC items
|
|
||||||
if (!isDocsPage || !tocs?.length) return null;
|
|
||||||
|
|
||||||
const chevronIcon = isExpanded ? (
|
|
||||||
<ChevronUp className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
<motion.div
|
|
||||||
ref={tocRef}
|
|
||||||
className="lg:hidden fixed top-16 left-0 right-0 z-50"
|
|
||||||
initial={{ y: -100, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
exit={{ y: -100, opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
|
||||||
>
|
|
||||||
<div className="w-full bg-background/95 backdrop-blur-sm border-b border-stone-200 dark:border-stone-800 shadow-sm">
|
|
||||||
<div className="sm:px-8 px-4 py-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full justify-between h-auto py-2 px-2 -mx-1 rounded-md hover:bg-transparent hover:text-inherit"
|
|
||||||
onClick={toggleExpanded}
|
|
||||||
aria-label={isExpanded ? 'Collapse table of contents' : 'Expand table of contents'}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<List className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
|
||||||
<span className="font-medium text-sm">On this page</span>
|
|
||||||
</div>
|
|
||||||
{chevronIcon}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{isExpanded && (
|
|
||||||
<motion.div
|
|
||||||
ref={contentRef}
|
|
||||||
className="mt-2 pb-2 max-h-[60vh] overflow-y-auto px-1 -mx-1"
|
|
||||||
initial={{ opacity: 0, height: 0 }}
|
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
|
||||||
exit={{ opacity: 0, height: 0 }}
|
|
||||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
|
||||||
>
|
|
||||||
<TocObserver
|
|
||||||
data={tocs}
|
|
||||||
activeId={activeId}
|
|
||||||
onActiveIdChange={setActiveId}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,87 +1,158 @@
|
|||||||
import { ArrowUpRight } from "lucide-react";
|
"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}
|
||||||
fill
|
fill
|
||||||
sizes="32px"
|
sizes="32px"
|
||||||
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">
|
||||||
</Link>
|
{navbar.logoText}
|
||||||
);
|
</h2>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { Dialog } from "@/components/ui/dialog";
|
|
||||||
import { SearchTrigger } from "@/components/SearchTrigger";
|
|
||||||
import { SearchModal } from "@/components/SearchModal";
|
|
||||||
import DocSearchComponent from "@/components/DocSearch";
|
|
||||||
import { DialogTrigger } from "@radix-ui/react-dialog";
|
|
||||||
|
|
||||||
interface SearchProps {
|
|
||||||
/**
|
|
||||||
* Specify which search engine to use.
|
|
||||||
* @default 'default'
|
|
||||||
*/
|
|
||||||
type?: "default" | "algolia";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Search({ type = "default" }: SearchProps) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
// The useEffect below is ONLY for the 'default' type, which is correct.
|
|
||||||
// DocSearch handles its own keyboard shortcut.
|
|
||||||
useEffect(() => {
|
|
||||||
if (type === 'default') {
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
|
|
||||||
event.preventDefault();
|
|
||||||
setIsOpen((open) => !open);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [type]);
|
|
||||||
|
|
||||||
if (type === "algolia") {
|
|
||||||
// Just render the component without passing any state props
|
|
||||||
return <DocSearchComponent />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logic for 'default' search
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<SearchTrigger />
|
|
||||||
</DialogTrigger>
|
|
||||||
<SearchModal isOpen={isOpen} setIsOpen={setIsOpen} />
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { EachRoute } from "@/lib/routes-config";
|
import { EachRoute } from "@/lib/routes";
|
||||||
import Anchor from "./anchor";
|
import 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
|
||||||
"font-medium sm:text-sm text-foreground/90 hover:text-foreground transition-colors",
|
data-search-lvl0={level === 0 && hasActiveChild ? "true" : undefined}
|
||||||
hasActiveChild ? "text-foreground" : "text-foreground/80"
|
className={cn(
|
||||||
)}>
|
"font-medium sm:text-sm text-foreground/90 hover:text-foreground transition-colors",
|
||||||
|
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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import clsx from "clsx";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { ScrollToTop } from "./scroll-to-top";
|
|
||||||
import { TocItem } from "@/lib/toc";
|
|
||||||
|
|
||||||
interface TocObserverProps {
|
|
||||||
data: TocItem[];
|
|
||||||
activeId?: string | null;
|
|
||||||
onActiveIdChange?: (id: string | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TocObserver({
|
|
||||||
data,
|
|
||||||
activeId: externalActiveId,
|
|
||||||
onActiveIdChange
|
|
||||||
}: TocObserverProps) {
|
|
||||||
const [internalActiveId, setInternalActiveId] = useState<string | null>(null);
|
|
||||||
const observer = useRef<IntersectionObserver | null>(null);
|
|
||||||
const [clickedId, setClickedId] = useState<string | null>(null);
|
|
||||||
const itemRefs = useRef<Map<string, HTMLAnchorElement>>(new Map());
|
|
||||||
|
|
||||||
// Use external activeId if provided, otherwise use internal state
|
|
||||||
const activeId = externalActiveId !== undefined ? externalActiveId : internalActiveId;
|
|
||||||
const setActiveId = onActiveIdChange || setInternalActiveId;
|
|
||||||
|
|
||||||
// Handle intersection observer for auto-highlighting
|
|
||||||
useEffect(() => {
|
|
||||||
const handleIntersect = (entries: IntersectionObserverEntry[]) => {
|
|
||||||
const visibleEntries = entries.filter(entry => entry.isIntersecting);
|
|
||||||
|
|
||||||
// Find the most recently scrolled-into-view element
|
|
||||||
const mostVisibleEntry = visibleEntries.reduce((prev, current) => {
|
|
||||||
// Prefer the entry that's more visible or higher on the page
|
|
||||||
const prevRatio = prev?.intersectionRatio || 0;
|
|
||||||
const currentRatio = current.intersectionRatio;
|
|
||||||
|
|
||||||
if (currentRatio > prevRatio) return current;
|
|
||||||
if (currentRatio === prevRatio &&
|
|
||||||
current.boundingClientRect.top < prev.boundingClientRect.top) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
}, visibleEntries[0]);
|
|
||||||
|
|
||||||
if (mostVisibleEntry && !clickedId) {
|
|
||||||
const newActiveId = mostVisibleEntry.target.id;
|
|
||||||
if (newActiveId !== activeId) {
|
|
||||||
setActiveId(newActiveId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
observer.current = new IntersectionObserver(handleIntersect, {
|
|
||||||
root: null,
|
|
||||||
rootMargin: "-20% 0px -70% 0px", // Adjusted margins for better section detection
|
|
||||||
threshold: [0, 0.1, 0.5, 0.9, 1], // Multiple thresholds for better accuracy
|
|
||||||
});
|
|
||||||
|
|
||||||
const elements = data.map((item) =>
|
|
||||||
document.getElementById(item.href.slice(1))
|
|
||||||
);
|
|
||||||
|
|
||||||
elements.forEach((el) => {
|
|
||||||
if (el && observer.current) {
|
|
||||||
observer.current.observe(el);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set initial active ID if none is set
|
|
||||||
if (!activeId && elements[0]) {
|
|
||||||
setActiveId(elements[0].id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (observer.current) {
|
|
||||||
elements.forEach((el) => {
|
|
||||||
if (el) {
|
|
||||||
observer.current!.unobserve(el);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [data, clickedId, activeId, setActiveId]);
|
|
||||||
|
|
||||||
const handleLinkClick = useCallback((id: string) => {
|
|
||||||
setClickedId(id);
|
|
||||||
setActiveId(id);
|
|
||||||
|
|
||||||
// Reset the clicked state after a delay to allow for smooth scrolling
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setClickedId(null);
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [setActiveId]);
|
|
||||||
|
|
||||||
// Function to check if an item has children
|
|
||||||
const hasChildren = (currentId: string, currentLevel: number) => {
|
|
||||||
const currentIndex = data.findIndex(item => item.href.slice(1) === currentId);
|
|
||||||
if (currentIndex === -1 || currentIndex === data.length - 1) return false;
|
|
||||||
|
|
||||||
const nextItem = data[currentIndex + 1];
|
|
||||||
return nextItem.level > currentLevel;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate scroll progress for the active section
|
|
||||||
const [scrollProgress, setScrollProgress] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => {
|
|
||||||
if (!activeId) return;
|
|
||||||
|
|
||||||
const activeElement = document.getElementById(activeId);
|
|
||||||
if (!activeElement) return;
|
|
||||||
|
|
||||||
const rect = activeElement.getBoundingClientRect();
|
|
||||||
const windowHeight = window.innerHeight;
|
|
||||||
const elementTop = rect.top;
|
|
||||||
const elementHeight = rect.height;
|
|
||||||
|
|
||||||
// Calculate how much of the element is visible
|
|
||||||
let progress = 0;
|
|
||||||
if (elementTop < windowHeight) {
|
|
||||||
progress = Math.min(1, (windowHeight - elementTop) / (windowHeight + elementHeight));
|
|
||||||
}
|
|
||||||
|
|
||||||
setScrollProgress(progress);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
|
||||||
}, [activeId]);
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<div className="relative text-sm text-foreground/70 hover:text-foreground transition-colors">
|
|
||||||
<div className="flex flex-col gap-0">
|
|
||||||
{data.map(({ href, level, text }, index) => {
|
|
||||||
const id = href.slice(1);
|
|
||||||
const isActive = activeId === id;
|
|
||||||
const indent = level > 1 ? (level - 1) * 20 : 0;
|
|
||||||
// Prefix with underscore to indicate intentionally unused
|
|
||||||
const _isParent = hasChildren(id, level);
|
|
||||||
const _isLastInLevel = index === data.length - 1 || data[index + 1].level <= level;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={href} className="relative">
|
|
||||||
{/* Simple L-shaped connector */}
|
|
||||||
{level > 1 && (
|
|
||||||
<div
|
|
||||||
className={clsx("absolute top-0 h-full w-6", {
|
|
||||||
"left-[6px]": indent === 20, // Level 2
|
|
||||||
"left-[22px]": indent === 40, // Level 3
|
|
||||||
"left-[38px]": indent === 60, // Level 4
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{/* Vertical line */}
|
|
||||||
<div className={clsx(
|
|
||||||
"absolute left-0 top-0 h-full w-px",
|
|
||||||
isActive ? "bg-primary/20 dark:bg-primary/30" : "bg-border/50 dark:bg-border/50"
|
|
||||||
)}>
|
|
||||||
{isActive && (
|
|
||||||
<motion.div
|
|
||||||
className="absolute left-0 top-0 w-full h-full bg-primary origin-top"
|
|
||||||
initial={{ scaleY: 0 }}
|
|
||||||
animate={{ scaleY: scrollProgress }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Horizontal line */}
|
|
||||||
<div className={clsx(
|
|
||||||
"absolute left-0 top-1/2 h-px w-6",
|
|
||||||
isActive ? "bg-primary/20 dark:bg-primary/30" : "bg-border/50 dark:bg-border/50"
|
|
||||||
)}>
|
|
||||||
{isActive && (
|
|
||||||
<motion.div
|
|
||||||
className="absolute left-0 top-0 h-full w-full bg-primary dark:bg-accent origin-left"
|
|
||||||
initial={{ scaleX: 0 }}
|
|
||||||
animate={{ scaleX: scrollProgress }}
|
|
||||||
transition={{ duration: 0.3, delay: 0.1 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
onClick={() => handleLinkClick(id)}
|
|
||||||
className={clsx(
|
|
||||||
"relative flex items-center py-2 transition-colors",
|
|
||||||
{
|
|
||||||
"text-primary dark:text-primary font-medium": isActive,
|
|
||||||
"text-muted-foreground hover:text-foreground dark:hover:text-foreground/90": !isActive,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
paddingLeft: `${indent}px`,
|
|
||||||
marginLeft: level > 1 ? '12px' : '0',
|
|
||||||
}}
|
|
||||||
ref={(el) => {
|
|
||||||
const map = itemRefs.current;
|
|
||||||
if (el) {
|
|
||||||
map.set(id, el);
|
|
||||||
} else {
|
|
||||||
map.delete(id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Circle indicator */}
|
|
||||||
<div className="relative w-4 h-4 flex items-center justify-center flex-shrink-0">
|
|
||||||
<div className={clsx(
|
|
||||||
"w-1.5 h-1.5 rounded-full transition-all duration-300 relative z-10",
|
|
||||||
{
|
|
||||||
"bg-primary scale-100 dark:bg-primary/90": isActive,
|
|
||||||
"bg-muted-foreground/30 dark:bg-muted-foreground/30 scale-75 group-hover:scale-100 group-hover:bg-primary/50 dark:group-hover:bg-primary/50": !isActive,
|
|
||||||
}
|
|
||||||
)}>
|
|
||||||
{isActive && (
|
|
||||||
<motion.div
|
|
||||||
className="absolute inset-0 rounded-full bg-primary/20 dark:bg-primary/30"
|
|
||||||
initial={{ scale: 1 }}
|
|
||||||
animate={{ scale: 1.8 }}
|
|
||||||
transition={{
|
|
||||||
duration: 2,
|
|
||||||
repeat: Infinity,
|
|
||||||
repeatType: "reverse"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="truncate text-sm">
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Add scroll to top link at the bottom of TOC */}
|
|
||||||
<ScrollToTop className="mt-6" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,29 @@
|
|||||||
import { getDocsTocs } from "@/lib/markdown";
|
"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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
353
components/ui/icon-cloud.tsx
Normal file
353
components/ui/icon-cloud.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState, useMemo } from "react";
|
||||||
|
import { renderToString } from "react-dom/server";
|
||||||
|
|
||||||
|
interface Icon {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
scale: number;
|
||||||
|
opacity: number;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IconCloudProps {
|
||||||
|
icons?: React.ReactNode[];
|
||||||
|
images?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function easeOutCubic(t: number): number {
|
||||||
|
return 1 - Math.pow(1 - t, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconCloud({ icons, images }: IconCloudProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
// const [iconPositions, setIconPositions] = useState<Icon[]>([]);
|
||||||
|
const iconPositions = useMemo<Icon[]>(() => {
|
||||||
|
const items = icons || images || [];
|
||||||
|
const newIcons: Icon[] = [];
|
||||||
|
const numIcons = items.length || 20;
|
||||||
|
|
||||||
|
// Fibonacci sphere parameters
|
||||||
|
const offset = 2 / numIcons;
|
||||||
|
const increment = Math.PI * (3 - Math.sqrt(5));
|
||||||
|
|
||||||
|
for (let i = 0; i < numIcons; i++) {
|
||||||
|
const y = i * offset - 1 + offset / 2;
|
||||||
|
const r = Math.sqrt(1 - y * y);
|
||||||
|
const phi = i * increment;
|
||||||
|
|
||||||
|
const x = Math.cos(phi) * r;
|
||||||
|
const z = Math.sin(phi) * r;
|
||||||
|
|
||||||
|
newIcons.push({
|
||||||
|
x: x * 100,
|
||||||
|
y: y * 100,
|
||||||
|
z: z * 100,
|
||||||
|
scale: 1,
|
||||||
|
opacity: 1,
|
||||||
|
id: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return newIcons;
|
||||||
|
}, [icons, images]);
|
||||||
|
const [rotation] = useState({ x: 0, y: 0 });
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 });
|
||||||
|
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||||
|
const [targetRotation, setTargetRotation] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
distance: number;
|
||||||
|
startTime: number;
|
||||||
|
duration: number;
|
||||||
|
} | null>(null);
|
||||||
|
const animationFrameRef = useRef<number>(undefined);
|
||||||
|
const rotationRef = useRef(rotation);
|
||||||
|
const iconCanvasesRef = useRef<HTMLCanvasElement[]>([]);
|
||||||
|
const imagesLoadedRef = useRef<boolean[]>([]);
|
||||||
|
|
||||||
|
// Create icon canvases once when icons/images change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!icons && !images) return;
|
||||||
|
|
||||||
|
const items = icons || images || [];
|
||||||
|
imagesLoadedRef.current = new Array(items.length).fill(false);
|
||||||
|
|
||||||
|
const newIconCanvases = items.map((item, index) => {
|
||||||
|
const offscreen = document.createElement("canvas");
|
||||||
|
offscreen.width = 40;
|
||||||
|
offscreen.height = 40;
|
||||||
|
const offCtx = offscreen.getContext("2d");
|
||||||
|
|
||||||
|
if (offCtx) {
|
||||||
|
if (images) {
|
||||||
|
// Handle image URLs directly
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.src = items[index] as string;
|
||||||
|
img.onload = () => {
|
||||||
|
offCtx.clearRect(0, 0, offscreen.width, offscreen.height);
|
||||||
|
|
||||||
|
// Create circular clipping path
|
||||||
|
offCtx.beginPath();
|
||||||
|
offCtx.arc(20, 20, 20, 0, Math.PI * 2);
|
||||||
|
offCtx.closePath();
|
||||||
|
offCtx.clip();
|
||||||
|
|
||||||
|
// Draw the image
|
||||||
|
offCtx.drawImage(img, 0, 0, 40, 40);
|
||||||
|
|
||||||
|
imagesLoadedRef.current[index] = true;
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Handle SVG icons
|
||||||
|
offCtx.scale(0.4, 0.4);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const svgString = renderToString(item as React.ReactElement<any>);
|
||||||
|
const img = new Image();
|
||||||
|
img.src = "data:image/svg+xml;base64," + btoa(svgString);
|
||||||
|
img.onload = () => {
|
||||||
|
offCtx.clearRect(0, 0, offscreen.width, offscreen.height);
|
||||||
|
offCtx.drawImage(img, 0, 0);
|
||||||
|
imagesLoadedRef.current[index] = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return offscreen;
|
||||||
|
});
|
||||||
|
|
||||||
|
iconCanvasesRef.current = newIconCanvases;
|
||||||
|
}, [icons, images]);
|
||||||
|
|
||||||
|
// Generate initial icon positions on a sphere
|
||||||
|
// useEffect(() => {
|
||||||
|
// const items = icons || images || [];
|
||||||
|
// const newIcons: Icon[] = [];
|
||||||
|
// const numIcons = items.length || 20;
|
||||||
|
|
||||||
|
// // Fibonacci sphere parameters
|
||||||
|
// const offset = 2 / numIcons;
|
||||||
|
// const increment = Math.PI * (3 - Math.sqrt(5));
|
||||||
|
|
||||||
|
// for (let i = 0; i < numIcons; i++) {
|
||||||
|
// const y = i * offset - 1 + offset / 2;
|
||||||
|
// const r = Math.sqrt(1 - y * y);
|
||||||
|
// const phi = i * increment;
|
||||||
|
|
||||||
|
// const x = Math.cos(phi) * r;
|
||||||
|
// const z = Math.sin(phi) * r;
|
||||||
|
|
||||||
|
// newIcons.push({
|
||||||
|
// x: x * 100,
|
||||||
|
// y: y * 100,
|
||||||
|
// z: z * 100,
|
||||||
|
// scale: 1,
|
||||||
|
// opacity: 1,
|
||||||
|
// id: i,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// setIconPositions(newIcons);
|
||||||
|
// }, [icons, images]);
|
||||||
|
|
||||||
|
// Handle mouse events
|
||||||
|
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect || !canvasRef.current) return;
|
||||||
|
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
const ctx = canvasRef.current.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
iconPositions.forEach((icon) => {
|
||||||
|
const cosX = Math.cos(rotationRef.current.x);
|
||||||
|
const sinX = Math.sin(rotationRef.current.x);
|
||||||
|
const cosY = Math.cos(rotationRef.current.y);
|
||||||
|
const sinY = Math.sin(rotationRef.current.y);
|
||||||
|
|
||||||
|
const rotatedX = icon.x * cosY - icon.z * sinY;
|
||||||
|
const rotatedZ = icon.x * sinY + icon.z * cosY;
|
||||||
|
const rotatedY = icon.y * cosX + rotatedZ * sinX;
|
||||||
|
|
||||||
|
const screenX = canvasRef.current!.width / 2 + rotatedX;
|
||||||
|
const screenY = canvasRef.current!.height / 2 + rotatedY;
|
||||||
|
|
||||||
|
const scale = (rotatedZ + 200) / 300;
|
||||||
|
const radius = 20 * scale;
|
||||||
|
const dx = x - screenX;
|
||||||
|
const dy = y - screenY;
|
||||||
|
|
||||||
|
if (dx * dx + dy * dy < radius * radius) {
|
||||||
|
const targetX = -Math.atan2(
|
||||||
|
icon.y,
|
||||||
|
Math.sqrt(icon.x * icon.x + icon.z * icon.z),
|
||||||
|
);
|
||||||
|
const targetY = Math.atan2(icon.x, icon.z);
|
||||||
|
|
||||||
|
const currentX = rotationRef.current.x;
|
||||||
|
const currentY = rotationRef.current.y;
|
||||||
|
const distance = Math.sqrt(
|
||||||
|
Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
const duration = Math.min(2000, Math.max(800, distance * 1000));
|
||||||
|
|
||||||
|
setTargetRotation({
|
||||||
|
x: targetX,
|
||||||
|
y: targetY,
|
||||||
|
startX: currentX,
|
||||||
|
startY: currentY,
|
||||||
|
distance,
|
||||||
|
startTime: performance.now(),
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsDragging(true);
|
||||||
|
setLastMousePos({ x: e.clientX, y: e.clientY });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
if (rect) {
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
setMousePos({ x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
const deltaX = e.clientX - lastMousePos.x;
|
||||||
|
const deltaY = e.clientY - lastMousePos.y;
|
||||||
|
|
||||||
|
rotationRef.current = {
|
||||||
|
x: rotationRef.current.x + deltaY * 0.002,
|
||||||
|
y: rotationRef.current.y + deltaX * 0.002,
|
||||||
|
};
|
||||||
|
|
||||||
|
setLastMousePos({ x: e.clientX, y: e.clientY });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Animation and rendering
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas?.getContext("2d");
|
||||||
|
if (!canvas || !ctx) return;
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const centerX = canvas.width / 2;
|
||||||
|
const centerY = canvas.height / 2;
|
||||||
|
const maxDistance = Math.sqrt(centerX * centerX + centerY * centerY);
|
||||||
|
const dx = mousePos.x - centerX;
|
||||||
|
const dy = mousePos.y - centerY;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const speed = 0.003 + (distance / maxDistance) * 0.01;
|
||||||
|
|
||||||
|
if (targetRotation) {
|
||||||
|
const elapsed = performance.now() - targetRotation.startTime;
|
||||||
|
const progress = Math.min(1, elapsed / targetRotation.duration);
|
||||||
|
const easedProgress = easeOutCubic(progress);
|
||||||
|
|
||||||
|
rotationRef.current = {
|
||||||
|
x:
|
||||||
|
targetRotation.startX +
|
||||||
|
(targetRotation.x - targetRotation.startX) * easedProgress,
|
||||||
|
y:
|
||||||
|
targetRotation.startY +
|
||||||
|
(targetRotation.y - targetRotation.startY) * easedProgress,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (progress >= 1) {
|
||||||
|
setTargetRotation(null);
|
||||||
|
}
|
||||||
|
} else if (!isDragging) {
|
||||||
|
rotationRef.current = {
|
||||||
|
x: rotationRef.current.x + (dy / canvas.height) * speed,
|
||||||
|
y: rotationRef.current.y + (dx / canvas.width) * speed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
iconPositions.forEach((icon, index) => {
|
||||||
|
const cosX = Math.cos(rotationRef.current.x);
|
||||||
|
const sinX = Math.sin(rotationRef.current.x);
|
||||||
|
const cosY = Math.cos(rotationRef.current.y);
|
||||||
|
const sinY = Math.sin(rotationRef.current.y);
|
||||||
|
|
||||||
|
const rotatedX = icon.x * cosY - icon.z * sinY;
|
||||||
|
const rotatedZ = icon.x * sinY + icon.z * cosY;
|
||||||
|
const rotatedY = icon.y * cosX + rotatedZ * sinX;
|
||||||
|
|
||||||
|
const scale = (rotatedZ + 200) / 300;
|
||||||
|
const opacity = Math.max(0.2, Math.min(1, (rotatedZ + 150) / 200));
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(
|
||||||
|
canvas.width / 2 + rotatedX,
|
||||||
|
canvas.height / 2 + rotatedY,
|
||||||
|
);
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
ctx.globalAlpha = opacity;
|
||||||
|
|
||||||
|
if (icons || images) {
|
||||||
|
// Only try to render icons/images if they exist
|
||||||
|
if (
|
||||||
|
iconCanvasesRef.current[index] &&
|
||||||
|
imagesLoadedRef.current[index]
|
||||||
|
) {
|
||||||
|
ctx.drawImage(iconCanvasesRef.current[index], -20, -20, 40, 40);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show numbered circles if no icons/images are provided
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, 20, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = "#4444ff";
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = "white";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.font = "16px Arial";
|
||||||
|
ctx.fillText(`${icon.id + 1}`, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
});
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
animate();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [icons, images, iconPositions, isDragging, mousePos, targetRotation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={400}
|
||||||
|
height={400}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
className="rounded-full"
|
||||||
|
aria-label="Interactive 3D Icon Cloud"
|
||||||
|
role="img"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,9 +33,9 @@ const ScrollBar = React.forwardRef<
|
|||||||
className={cn(
|
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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -32,7 +32,7 @@ const toggleVariants = cva(
|
|||||||
const Toggle = React.forwardRef<
|
const Toggle = React.forwardRef<
|
||||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||||
VariantProps<typeof toggleVariants>
|
VariantProps<typeof toggleVariants>
|
||||||
>(({ className, variant, size, ...props }, ref) => (
|
>(({ className, variant, size, ...props }, ref) => (
|
||||||
<TogglePrimitive.Root
|
<TogglePrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"pages": [
|
|
||||||
"licensing"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
---
|
|
||||||
title: Changelog
|
|
||||||
description: Latest updates and changes to WooNooW
|
|
||||||
date: 2024-01-31
|
|
||||||
---
|
|
||||||
|
|
||||||
## Initial Release
|
|
||||||
|
|
||||||
<Release version="1.0.0" date="2024-01-31" title="Initial Public Release">
|
|
||||||
<Changes type="added">
|
|
||||||
- Core plugin functionality for WooCommerce enhancement.
|
|
||||||
- Licensing module with OAuth activation flow.
|
|
||||||
- Subscription management and payment gateway integration.
|
|
||||||
- Extensive hook system for developers.
|
|
||||||
</Changes>
|
|
||||||
</Release>
|
|
||||||
@@ -1,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
|
|
||||||
@@ -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.
|
|
||||||
@@ -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;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
---
|
|
||||||
title: Developer Guide
|
|
||||||
description: Extend and customize WooNooW.
|
|
||||||
---
|
|
||||||
|
|
||||||
## Core Concepts
|
|
||||||
|
|
||||||
WooNooW is built with extensibility in mind.
|
|
||||||
|
|
||||||
### [Addons System](/docs/developer/addons/module-integration)
|
|
||||||
Learn how to create custom modules that plug into the WooNooW ecosystem.
|
|
||||||
|
|
||||||
### [React Integration](/docs/developer/addons/react-integration)
|
|
||||||
Understand how we bridge PHP and React to create seamless admin interfaces.
|
|
||||||
|
|
||||||
### [API Reference](/docs/developer/api/licensing)
|
|
||||||
Detailed documentation of our REST API endpoints.
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
---
|
|
||||||
title : Development
|
|
||||||
description : for Development server and production
|
|
||||||
date : 10-12-2024
|
|
||||||
---
|
|
||||||
|
|
||||||
## Heading 2
|
|
||||||
|
|
||||||
this is regular text written in markdown format with `inline code`, **bold**, and *italic*
|
|
||||||
|
|
||||||
### Heading 3
|
|
||||||
|
|
||||||
example of ordered list format :
|
|
||||||
|
|
||||||
- list one
|
|
||||||
- sub list
|
|
||||||
- list two
|
|
||||||
- list three
|
|
||||||
|
|
||||||
#### Heading 4
|
|
||||||
|
|
||||||
Below is an example of how to write a code block :
|
|
||||||
|
|
||||||
````plaintext
|
|
||||||
```javascript:main.js showLineNumbers {3-4}
|
|
||||||
function isRocketAboutToCrash() {
|
|
||||||
// Check if the rocket is stable
|
|
||||||
if (!isStable()) {
|
|
||||||
NoCrash(); // Prevent the crash
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
````
|
|
||||||
|
|
||||||
example note :
|
|
||||||
```plaintext
|
|
||||||
<Note type="note" title="Note">
|
|
||||||
This is a general note to convey information to the user.
|
|
||||||
</Note>
|
|
||||||
```
|
|
||||||
|
|
||||||
displaying an image in markdown format :
|
|
||||||
|
|
||||||
```plaintext
|
|
||||||

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

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

|
||||||
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
|
||||||
|
|
||||||
@@ -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.
|
||||||
36
docs/configuration/appearance.mdx
Normal file
36
docs/configuration/appearance.mdx
Normal 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.
|
||||||
261
docs/configuration/email.mdx
Normal file
261
docs/configuration/email.mdx
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
## 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.
|
||||||
243
docs/developer/addons/module-integration.mdx
Normal file
243
docs/developer/addons/module-integration.mdx
Normal 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
|
||||||
20
docs/developer/overview.mdx
Normal file
20
docs/developer/overview.mdx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
title: Developer Guide
|
||||||
|
description: Extend and customize WooNooW.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
WooNooW is built with extensibility in mind.
|
||||||
|
|
||||||
|
<CardGroup cols={2}>
|
||||||
|
<Card title="Addons System" icon="Package" href="/docs/developer/addons/module-integration">
|
||||||
|
Learn how to create custom modules that plug into the WooNooW ecosystem.
|
||||||
|
</Card>
|
||||||
|
<Card title="React Integration" icon="Plug" href="/docs/developer/addons/react-integration">
|
||||||
|
Understand how we bridge PHP and React to create seamless admin interfaces.
|
||||||
|
</Card>
|
||||||
|
<Card title="API Reference" icon="Zap" href="/docs/developer/api/licensing">
|
||||||
|
Detailed documentation of our REST API endpoints.
|
||||||
|
</Card>
|
||||||
|
</CardGroup>
|
||||||
218
docs/developer/software-updates.mdx
Normal file
218
docs/developer/software-updates.mdx
Normal 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
|
||||||
45
docs/developer/store-owner.mdx
Normal file
45
docs/developer/store-owner.mdx
Normal 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.
|
||||||
@@ -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
9
docs/features/index.mdx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
title : Features
|
||||||
|
description : Showcases the features of Woonoow
|
||||||
|
date : 10-12-2024
|
||||||
|
---
|
||||||
|
|
||||||
|
This page showcases the features of Woonoow.
|
||||||
|
|
||||||
|
<Outlet path="/features" />
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user