feat(docs): add docs and restructure
This commit is contained in:
parent
afc67bc0b9
commit
498374195d
BIN
web/bun.lockb
BIN
web/bun.lockb
Binary file not shown.
@ -1,15 +1,17 @@
|
|||||||
import type { Config } from 'drizzle-kit';
|
import { config } from "dotenv";
|
||||||
import { config } from 'dotenv';
|
import type { Config } from "drizzle-kit";
|
||||||
|
|
||||||
config({
|
config({
|
||||||
path: `.env.local`,
|
path: `.env.local`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
schema: './src/db/schema.ts',
|
schema: "./src/db/schema.ts",
|
||||||
driver: 'pg',
|
driver: "pg",
|
||||||
out: './drizzle',
|
out: "./drizzle",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
connectionString: (process.env.POSTGRES_URL as string) + ( process.env.POSTGRES_SSL !== "false" ? '?ssl=true' : ""),
|
connectionString:
|
||||||
|
(process.env.POSTGRES_URL as string) +
|
||||||
|
(process.env.POSTGRES_SSL !== "false" ? "?ssl=true" : ""),
|
||||||
},
|
},
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
9
web/mdx-components.tsx
Normal file
9
web/mdx-components.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import * as mdxComponents from "@/components/docs/mdx";
|
||||||
|
import { type MDXComponents } from "mdx/types";
|
||||||
|
|
||||||
|
export function useMDXComponents(components: MDXComponents) {
|
||||||
|
return {
|
||||||
|
...components,
|
||||||
|
...mdxComponents,
|
||||||
|
};
|
||||||
|
}
|
@ -1,8 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
eslint: {
|
|
||||||
ignoreDuringBuilds: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = nextConfig;
|
|
23
web/next.config.mjs
Normal file
23
web/next.config.mjs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { recmaPlugins } from "./src/mdx/recma.mjs";
|
||||||
|
import { rehypePlugins } from "./src/mdx/rehype.mjs";
|
||||||
|
import { remarkPlugins } from "./src/mdx/remark.mjs";
|
||||||
|
import withSearch from "./src/mdx/search.mjs";
|
||||||
|
import nextMDX from "@next/mdx";
|
||||||
|
|
||||||
|
const withMDX = nextMDX({
|
||||||
|
options: {
|
||||||
|
remarkPlugins,
|
||||||
|
rehypePlugins,
|
||||||
|
recmaPlugins,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
pageExtensions: ["js", "jsx", "ts", "tsx", "mdx"],
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withSearch(withMDX(nextConfig));
|
@ -15,11 +15,17 @@
|
|||||||
"db-dev": "bun run db-up && bun run migrate-local"
|
"db-dev": "bun run db-up && bun run migrate-local"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@algolia/autocomplete-core": "^1.13.0",
|
||||||
"@aws-sdk/client-s3": "^3.472.0",
|
"@aws-sdk/client-s3": "^3.472.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.472.0",
|
"@aws-sdk/s3-request-presigner": "^3.472.0",
|
||||||
"@clerk/nextjs": "^4.27.4",
|
"@clerk/nextjs": "^4.27.4",
|
||||||
|
"@headlessui/react": "^1.7.17",
|
||||||
|
"@headlessui/tailwindcss": "^0.2.0",
|
||||||
"@hookform/resolvers": "^3.3.2",
|
"@hookform/resolvers": "^3.3.2",
|
||||||
|
"@mdx-js/loader": "^3.0.0",
|
||||||
|
"@mdx-js/react": "^3.0.0",
|
||||||
"@neondatabase/serverless": "^0.6.0",
|
"@neondatabase/serverless": "^0.6.0",
|
||||||
|
"@next/mdx": "^14.0.4",
|
||||||
"@radix-ui/react-accordion": "^1.1.2",
|
"@radix-ui/react-accordion": "^1.1.2",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
@ -34,41 +40,54 @@
|
|||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-toggle": "^1.0.3",
|
"@radix-ui/react-toggle": "^1.0.3",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
|
"@sindresorhus/slugify": "^2.2.1",
|
||||||
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"@tanstack/react-table": "^8.10.7",
|
"@tanstack/react-table": "^8.10.7",
|
||||||
"@types/jsonwebtoken": "^9.0.5",
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/react-highlight-words": "^0.16.7",
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
|
"acorn": "^8.11.2",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"date-fns": "^3.0.5",
|
"date-fns": "^3.0.5",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"drizzle-orm": "^0.29.1",
|
"drizzle-orm": "^0.29.1",
|
||||||
"drizzle-zod": "^0.5.1",
|
"drizzle-zod": "^0.5.1",
|
||||||
|
"fast-glob": "^3.3.2",
|
||||||
|
"flexsearch": "^0.7.31",
|
||||||
|
"framer-motion": "^10.16.16",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
|
"mdast-util-to-string": "^4.0.0",
|
||||||
|
"mdx-annotations": "^0.1.4",
|
||||||
"nanoid": "^5.0.4",
|
"nanoid": "^5.0.4",
|
||||||
"next": "14.0.3",
|
"next": "14.0.3",
|
||||||
"next-plausible": "^3.12.0",
|
"next-plausible": "^3.12.0",
|
||||||
|
"next-themes": "^0.2.1",
|
||||||
"next-usequerystate": "^1.13.2",
|
"next-usequerystate": "^1.13.2",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-day-picker": "^8.9.1",
|
"react-day-picker": "^8.9.1",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
"react-highlight-words": "^0.20.0",
|
||||||
"react-hook-form": "^7.48.2",
|
"react-hook-form": "^7.48.2",
|
||||||
"react-use-websocket": "^4.5.0",
|
"react-use-websocket": "^4.5.0",
|
||||||
|
"remark": "^15.0.1",
|
||||||
|
"remark-gfm": "^4.0.0",
|
||||||
|
"remark-mdx": "^3.0.0",
|
||||||
|
"shiki": "^0.14.7",
|
||||||
"shikiji": "^0.9.3",
|
"shikiji": "^0.9.3",
|
||||||
|
"simple-functional-loader": "^1.2.1",
|
||||||
"sonner": "^1.2.4",
|
"sonner": "^1.2.4",
|
||||||
"swr": "^2.2.4",
|
"swr": "^2.2.4",
|
||||||
"tailwind-merge": "^2.1.0",
|
"tailwind-merge": "^2.1.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"unist-util-filter": "^5.0.1",
|
||||||
|
"unist-util-visit": "^5.0.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"zod": "^3.22.4",
|
"zod": "^3.22.4",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.4.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint-config-next": "^14.0.4",
|
|
||||||
"eslint-config-prettier": "^8.6.0",
|
|
||||||
"eslint-config-turbo": "latest",
|
|
||||||
"eslint-plugin-prettier": "4.2.1",
|
|
||||||
"prettier-plugin-tailwindcss": "0.2.5",
|
|
||||||
"@trivago/prettier-plugin-sort-imports": "4.1.1",
|
"@trivago/prettier-plugin-sort-imports": "4.1.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
@ -80,10 +99,16 @@
|
|||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"drizzle-kit": "^0.20.6",
|
"drizzle-kit": "^0.20.6",
|
||||||
"eslint": "8.34.0",
|
"eslint": "8.34.0",
|
||||||
|
"eslint-config-next": "^14.0.4",
|
||||||
|
"eslint-config-prettier": "^8.6.0",
|
||||||
|
"eslint-config-turbo": "latest",
|
||||||
|
"eslint-plugin-prettier": "4.2.1",
|
||||||
"eslint-plugin-unused-imports": "^3.0.0",
|
"eslint-plugin-unused-imports": "^3.0.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"postgres": "^3.4.3",
|
"postgres": "^3.4.3",
|
||||||
"prettier": "2.8.6",
|
"prettier": "2.8.6",
|
||||||
|
"prettier-plugin-tailwindcss": "0.2.5",
|
||||||
|
"sharp": "^0.33.1",
|
||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { parseDataSafe } from "../../../lib/parseDataSafe";
|
import { parseDataSafe } from "../../../../lib/parseDataSafe";
|
||||||
import { handleResourceUpload } from "@/server/resource";
|
import { handleResourceUpload } from "@/server/resource";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
@ -1,5 +1,5 @@
|
|||||||
import { parseDataSafe } from "../../../lib/parseDataSafe";
|
import { parseDataSafe } from "../../../../lib/parseDataSafe";
|
||||||
import { createRun } from "../../../server/createRun";
|
import { createRun } from "../../../../server/createRun";
|
||||||
import { db } from "@/db/db";
|
import { db } from "@/db/db";
|
||||||
import { deploymentsTable } from "@/db/schema";
|
import { deploymentsTable } from "@/db/schema";
|
||||||
import { isKeyRevoked } from "@/server/curdApiKeys";
|
import { isKeyRevoked } from "@/server/curdApiKeys";
|
@ -1,4 +1,4 @@
|
|||||||
import { parseDataSafe } from "../../../lib/parseDataSafe";
|
import { parseDataSafe } from "../../../../lib/parseDataSafe";
|
||||||
import { db } from "@/db/db";
|
import { db } from "@/db/db";
|
||||||
import { workflowRunOutputs, workflowRunsTable } from "@/db/schema";
|
import { workflowRunOutputs, workflowRunsTable } from "@/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
@ -1,4 +1,4 @@
|
|||||||
import { parseJWT } from "../../../server/parseJWT";
|
import { parseJWT } from "../../../../server/parseJWT";
|
||||||
import { db } from "@/db/db";
|
import { db } from "@/db/db";
|
||||||
import {
|
import {
|
||||||
workflowAPIType,
|
workflowAPIType,
|
@ -1,4 +1,4 @@
|
|||||||
import { getFileDownloadUrl } from "../../../server/getFileDownloadUrl";
|
import { getFileDownloadUrl } from "../../../../server/getFileDownloadUrl";
|
||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@ -1,3 +1,21 @@
|
|||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--shiki-color-text: theme('colors.white');
|
||||||
|
--shiki-token-constant: theme('colors.emerald.300');
|
||||||
|
--shiki-token-string: theme('colors.emerald.300');
|
||||||
|
--shiki-token-comment: theme('colors.zinc.500');
|
||||||
|
--shiki-token-keyword: theme('colors.sky.300');
|
||||||
|
--shiki-token-parameter: theme('colors.pink.300');
|
||||||
|
--shiki-token-function: theme('colors.violet.300');
|
||||||
|
--shiki-token-string-expression: theme('colors.emerald.300');
|
||||||
|
--shiki-token-punctuation: theme('colors.zinc.200');
|
||||||
|
}
|
||||||
|
|
||||||
|
[inert] ::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
@ -57,6 +57,13 @@ export default function RootLayout({
|
|||||||
<NavbarRight />
|
<NavbarRight />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="link"
|
||||||
|
className="rounded-full aspect-square p-2 mr-4"
|
||||||
|
>
|
||||||
|
<a href="/docs">Docs</a>
|
||||||
|
</Button>
|
||||||
<UserButton />
|
<UserButton />
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
@ -1,5 +1,5 @@
|
|||||||
import { DeploymentsTable, RunsTable } from "../../../components/RunsTable";
|
import { DeploymentsTable, RunsTable } from "../../../../components/RunsTable";
|
||||||
import { findFirstTableWithVersion } from "../../../server/findFirstTableWithVersion";
|
import { findFirstTableWithVersion } from "../../../../server/findFirstTableWithVersion";
|
||||||
import { MachinesWSMain } from "@/components/MachinesWS";
|
import { MachinesWSMain } from "@/components/MachinesWS";
|
||||||
import { VersionDetails } from "@/components/VersionDetails";
|
import { VersionDetails } from "@/components/VersionDetails";
|
||||||
import {
|
import {
|
49
web/src/app/(docs)/docs/install/page.mdx
Normal file
49
web/src/app/(docs)/docs/install/page.mdx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
export const metadata = {
|
||||||
|
title: 'Quickstart',
|
||||||
|
description:
|
||||||
|
'This guide will get you all set up and ready to use the Protocol API. We’ll cover how to get started an API client and how to make your first API request.',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Getting stated
|
||||||
|
|
||||||
|
Install Comfy Deploy's plugin on your local machine to get started with deploying workflow.
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
|
||||||
|
```bash {{ title: 'git' }}
|
||||||
|
cd custom_nodes
|
||||||
|
git clone https://github.com/BennyKok/comfyui-deploy.git
|
||||||
|
```
|
||||||
|
|
||||||
|
```js {{ language: 'js', title: 'ComfyUI Manager' }}
|
||||||
|
// Install with ComfyUI Manager
|
||||||
|
Search `ComfyUI Deploy`
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
## ComfyUI Manager
|
||||||
|
|
||||||
|
Install from ComfyUI Manager
|
||||||
|
<Image src="https://media.discordapp.net/attachments/1187301526646030386/1189400730101104660/CleanShot_2023-12-27_at_10.53.272x.png"/>
|
||||||
|
|
||||||
|
## Setup your workflow
|
||||||
|
|
||||||
|
Add ComfyDeploy node
|
||||||
|
|
||||||
|
<Image className="max-w-xl" src="https://cdn.discordapp.com/attachments/1187301526646030386/1189400819741761546/CleanShot_2023-12-27_at_10.54.052x.png"/>
|
||||||
|
|
||||||
|
Set a name for your workflow
|
||||||
|
|
||||||
|
<Image className="max-w-xl" src="https://cdn.discordapp.com/attachments/1187301526646030386/1189400882773766184/CleanShot_2023-12-27_at_10.54.212x.png"/>
|
||||||
|
|
||||||
|
Set up your API key
|
||||||
|
|
||||||
|
<Image className="w-fit max-h-48" src="https://cdn.discordapp.com/attachments/1189594955279245403/1189594974149431326/CleanShot_2023-12-27_at_23.45.13.png"/>
|
||||||
|
Open the settings panel and then set your API key here, if you dont have one, create here <a href="/api-keys">Create API Key</a>
|
||||||
|
<Image className="max-w-lg" src="https://cdn.discordapp.com/attachments/1187301526646030386/1189401104472080514/CleanShot_2023-12-27_at_10.54.372x.png"/>
|
||||||
|
|
||||||
|
|
||||||
|
## What's next?
|
||||||
|
|
||||||
|
Now, hit the Deploy button and your workflow will be deployed to ComfyUI's server. Check out <a href="/workflows">your workflows here</a>.
|
||||||
|
|
40
web/src/app/(docs)/docs/layout.tsx
Normal file
40
web/src/app/(docs)/docs/layout.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { Providers } from "./providers";
|
||||||
|
import "@/app/(app)/globals.css";
|
||||||
|
import { Layout } from "@/components/docs/Layout";
|
||||||
|
import { type Section } from "@/components/docs/SectionProvider";
|
||||||
|
import glob from "fast-glob";
|
||||||
|
import { type Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
template: "%s - Protocol API Reference",
|
||||||
|
default: "Protocol API Reference",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const pages = await glob("**/*.mdx", { cwd: "src/app/(docs)/docs" });
|
||||||
|
const allSectionsEntries = (await Promise.all(
|
||||||
|
pages.map(async (filename) => [
|
||||||
|
`/${filename.replace(/(^|\/)page\.mdx$/, "")}`,
|
||||||
|
(await import(`./${filename}`)).sections,
|
||||||
|
])
|
||||||
|
)) as Array<[string, Array<Section>]>;
|
||||||
|
const allSections = Object.fromEntries(allSectionsEntries);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="en" className="h-full" suppressHydrationWarning>
|
||||||
|
<body className="flex min-h-full bg-white antialiased dark:bg-zinc-900">
|
||||||
|
<Providers>
|
||||||
|
<div className="w-full">
|
||||||
|
<Layout allSections={allSections}>{children}</Layout>
|
||||||
|
</div>
|
||||||
|
</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
24
web/src/app/(docs)/docs/not-found.tsx
Normal file
24
web/src/app/(docs)/docs/not-found.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Button } from "@/components/docs/Button";
|
||||||
|
import { HeroPattern } from "@/components/docs/HeroPattern";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeroPattern />
|
||||||
|
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
|
||||||
|
<p className="text-sm font-semibold text-zinc-900 dark:text-white">
|
||||||
|
404
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">
|
||||||
|
Page not found
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
|
||||||
|
Sorry, we couldn’t find the page you’re looking for.
|
||||||
|
</p>
|
||||||
|
<Button href="/" arrow="right" className="mt-8">
|
||||||
|
Back to docs
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
34
web/src/app/(docs)/docs/page.mdx
Normal file
34
web/src/app/(docs)/docs/page.mdx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { Guides } from '@/components/docs/Guides'
|
||||||
|
import { Resources } from '@/components/docs/Resources'
|
||||||
|
import { HeroPattern } from '@/components/docs/HeroPattern'
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Comfy Deploy Documentation',
|
||||||
|
description:
|
||||||
|
'Get your comfy deploy setup running in minutes. Learn how to deploy your generative workflow to comfy deploy.',
|
||||||
|
}
|
||||||
|
|
||||||
|
<HeroPattern />
|
||||||
|
|
||||||
|
# Introduction
|
||||||
|
|
||||||
|
Open source comfyui deployment platform, a vercel for generative workflow infra.
|
||||||
|
|
||||||
|
<div className="not-prose mb-16 mt-6 flex gap-3">
|
||||||
|
<Button href="/docs/install" arrow="right">
|
||||||
|
<>Install</>
|
||||||
|
</Button>
|
||||||
|
<Button href="/api-keys" variant="outline">
|
||||||
|
<>Create API Key</>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ## Getting started {{ anchor: false }}
|
||||||
|
|
||||||
|
Get started by first installing Comfy Deploy plugin
|
||||||
|
|
||||||
|
<div className="not-prose">
|
||||||
|
<Button href="/api-keys" variant="text" arrow="right">
|
||||||
|
<>Get your API key</>
|
||||||
|
</Button>
|
||||||
|
</div> */}
|
37
web/src/app/(docs)/docs/providers.tsx
Normal file
37
web/src/app/(docs)/docs/providers.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ThemeProvider, useTheme } from "next-themes";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
function ThemeWatcher() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
|
||||||
|
function onMediaChange() {
|
||||||
|
const systemTheme = media.matches ? "dark" : "light";
|
||||||
|
if (resolvedTheme === systemTheme) {
|
||||||
|
setTheme("system");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMediaChange();
|
||||||
|
media.addEventListener("change", onMediaChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
media.removeEventListener("change", onMediaChange);
|
||||||
|
};
|
||||||
|
}, [resolvedTheme, setTheme]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider attribute="class" disableTransitionOnChange>
|
||||||
|
<ThemeWatcher />
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
@ -96,7 +96,7 @@ export function MachineSelect({
|
|||||||
setMachine(v);
|
setMachine(v);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[180px]">
|
<SelectTrigger className="w-[180px] text-start">
|
||||||
<SelectValue placeholder="Select a machine" />
|
<SelectValue placeholder="Select a machine" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
82
web/src/components/docs/Button.tsx
Normal file
82
web/src/components/docs/Button.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
function ArrowIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m11.5 6.5 3 3.5m0 0-3 3.5m3-3.5h-9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
primary:
|
||||||
|
"rounded-full bg-zinc-900 py-1 px-3 text-white hover:bg-zinc-700 dark:bg-emerald-400/10 dark:text-emerald-400 dark:ring-1 dark:ring-inset dark:ring-emerald-400/20 dark:hover:bg-emerald-400/10 dark:hover:text-emerald-300 dark:hover:ring-emerald-300",
|
||||||
|
secondary:
|
||||||
|
"rounded-full bg-zinc-100 py-1 px-3 text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800/40 dark:text-zinc-400 dark:ring-1 dark:ring-inset dark:ring-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-300",
|
||||||
|
filled:
|
||||||
|
"rounded-full bg-zinc-900 py-1 px-3 text-white hover:bg-zinc-700 dark:bg-emerald-500 dark:text-white dark:hover:bg-emerald-400",
|
||||||
|
outline:
|
||||||
|
"rounded-full py-1 px-3 text-zinc-700 ring-1 ring-inset ring-zinc-900/10 hover:bg-zinc-900/2.5 hover:text-zinc-900 dark:text-zinc-400 dark:ring-white/10 dark:hover:bg-white/5 dark:hover:text-white",
|
||||||
|
text: "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
type ButtonProps = {
|
||||||
|
variant?: keyof typeof variantStyles;
|
||||||
|
arrow?: "left" | "right";
|
||||||
|
} & (
|
||||||
|
| React.ComponentPropsWithoutRef<typeof Link>
|
||||||
|
| (React.ComponentPropsWithoutRef<"button"> & { href?: undefined })
|
||||||
|
);
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = "primary",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
arrow,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
className = clsx(
|
||||||
|
"inline-flex gap-0.5 justify-center overflow-hidden text-sm font-medium transition items-center ",
|
||||||
|
variantStyles[variant],
|
||||||
|
className
|
||||||
|
);
|
||||||
|
|
||||||
|
const arrowIcon = (
|
||||||
|
<ArrowIcon
|
||||||
|
className={clsx(
|
||||||
|
"h-5 w-5",
|
||||||
|
variant === "text" && "relative top-px",
|
||||||
|
arrow === "left" && "-ml-1 rotate-180",
|
||||||
|
arrow === "right" && "-mr-1"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
{arrow === "left" && arrowIcon}
|
||||||
|
{children}
|
||||||
|
{arrow === "right" && arrowIcon}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof props.href === "undefined") {
|
||||||
|
return (
|
||||||
|
<button className={className} {...props}>
|
||||||
|
{inner}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link className={className} {...props}>
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
380
web/src/components/docs/Code.tsx
Normal file
380
web/src/components/docs/Code.tsx
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Tag } from "@/components/docs/Tag";
|
||||||
|
import { Tab } from "@headlessui/react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {
|
||||||
|
Children,
|
||||||
|
createContext,
|
||||||
|
isValidElement,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
const languageNames: Record<string, string> = {
|
||||||
|
js: "JavaScript",
|
||||||
|
ts: "TypeScript",
|
||||||
|
javascript: "JavaScript",
|
||||||
|
typescript: "TypeScript",
|
||||||
|
php: "PHP",
|
||||||
|
python: "Python",
|
||||||
|
ruby: "Ruby",
|
||||||
|
go: "Go",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPanelTitle({
|
||||||
|
title,
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
language?: string;
|
||||||
|
}) {
|
||||||
|
if (title) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
if (language && language in languageNames) {
|
||||||
|
return languageNames[language];
|
||||||
|
}
|
||||||
|
return "Code";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClipboardIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeWidth="0"
|
||||||
|
d="M5.5 13.5v-5a2 2 0 0 1 2-2l.447-.894A2 2 0 0 1 9.737 4.5h.527a2 2 0 0 1 1.789 1.106l.447.894a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12.5 6.5a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2m5 0-.447-.894a2 2 0 0 0-1.79-1.106h-.527a2 2 0 0 0-1.789 1.106L7.5 6.5m5 0-1 1h-3l-1-1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyButton({ code }: { code: string }) {
|
||||||
|
const [copyCount, setCopyCount] = useState(0);
|
||||||
|
const copied = copyCount > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (copyCount > 0) {
|
||||||
|
const timeout = setTimeout(() => setCopyCount(0), 1000);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [copyCount]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={clsx(
|
||||||
|
"group/button absolute right-4 top-3.5 overflow-hidden rounded-full py-1 pl-2 pr-3 text-2xs font-medium opacity-0 backdrop-blur transition focus:opacity-100 group-hover:opacity-100",
|
||||||
|
copied
|
||||||
|
? "bg-emerald-400/10 ring-1 ring-inset ring-emerald-400/20"
|
||||||
|
: "bg-white/5 hover:bg-white/7.5 dark:bg-white/2.5 dark:hover:bg-white/5"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
window.navigator.clipboard.writeText(code).then(() => {
|
||||||
|
setCopyCount((count) => count + 1);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden={copied}
|
||||||
|
className={clsx(
|
||||||
|
"pointer-events-none flex items-center gap-0.5 text-zinc-400 transition duration-300",
|
||||||
|
copied && "-translate-y-1.5 opacity-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ClipboardIcon className="h-5 w-5 fill-zinc-500/20 stroke-zinc-500 transition-colors group-hover/button:stroke-zinc-400" />
|
||||||
|
Copy
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden={!copied}
|
||||||
|
className={clsx(
|
||||||
|
"pointer-events-none absolute inset-0 flex items-center justify-center text-emerald-400 transition duration-300",
|
||||||
|
!copied && "translate-y-1.5 opacity-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Copied!
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodePanelHeader({ tag, label }: { tag?: string; label?: string }) {
|
||||||
|
if (!tag && !label) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-9 items-center gap-2 border-y border-b-white/7.5 border-t-transparent bg-white/2.5 bg-zinc-900 px-4 dark:border-b-white/5 dark:bg-white/1">
|
||||||
|
{tag && (
|
||||||
|
<div className="dark flex">
|
||||||
|
<Tag variant="small">{tag}</Tag>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tag && label && (
|
||||||
|
<span className="h-0.5 w-0.5 rounded-full bg-zinc-500" />
|
||||||
|
)}
|
||||||
|
{label && (
|
||||||
|
<span className="font-mono text-xs text-zinc-400">{label}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodePanel({
|
||||||
|
children,
|
||||||
|
tag,
|
||||||
|
label,
|
||||||
|
code,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
tag?: string;
|
||||||
|
label?: string;
|
||||||
|
code?: string;
|
||||||
|
}) {
|
||||||
|
const child = Children.only(children);
|
||||||
|
|
||||||
|
if (isValidElement(child)) {
|
||||||
|
tag = child.props.tag ?? tag;
|
||||||
|
label = child.props.label ?? label;
|
||||||
|
code = child.props.code ?? code;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
throw new Error(
|
||||||
|
"`CodePanel` requires a `code` prop, or a child with a `code` prop."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group dark:bg-white/2.5">
|
||||||
|
<CodePanelHeader tag={tag} label={label} />
|
||||||
|
<div className="relative">
|
||||||
|
<pre className="overflow-x-auto p-4 text-xs text-white">{children}</pre>
|
||||||
|
<CopyButton code={code} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeGroupHeader({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
selectedIndex,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
selectedIndex: number;
|
||||||
|
}) {
|
||||||
|
const hasTabs = Children.count(children) > 1;
|
||||||
|
|
||||||
|
if (!title && !hasTabs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[calc(theme(spacing.12)+1px)] flex-wrap items-start gap-x-4 border-b border-zinc-700 bg-zinc-800 px-4 dark:border-zinc-800 dark:bg-transparent">
|
||||||
|
{title && (
|
||||||
|
<h3 className="mr-auto pt-3 text-xs font-semibold text-white">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
{hasTabs && (
|
||||||
|
<Tab.List className="-mb-px flex gap-4 text-xs font-medium">
|
||||||
|
{Children.map(children, (child, childIndex) => (
|
||||||
|
<Tab
|
||||||
|
className={clsx(
|
||||||
|
"border-b py-3 transition ui-not-focus-visible:outline-none",
|
||||||
|
childIndex === selectedIndex
|
||||||
|
? "border-emerald-500 text-emerald-400"
|
||||||
|
: "border-transparent text-zinc-400 hover:text-zinc-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getPanelTitle(isValidElement(child) ? child.props : {})}
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
|
</Tab.List>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeGroupPanels({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<typeof CodePanel>) {
|
||||||
|
const hasTabs = Children.count(children) > 1;
|
||||||
|
|
||||||
|
if (hasTabs) {
|
||||||
|
return (
|
||||||
|
<Tab.Panels>
|
||||||
|
{Children.map(children, (child) => (
|
||||||
|
<Tab.Panel>
|
||||||
|
<CodePanel {...props}>{child}</CodePanel>
|
||||||
|
</Tab.Panel>
|
||||||
|
))}
|
||||||
|
</Tab.Panels>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CodePanel {...props}>{children}</CodePanel>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePreventLayoutShift() {
|
||||||
|
const positionRef = useRef<HTMLElement>(null);
|
||||||
|
const rafRef = useRef<number>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (typeof rafRef.current !== "undefined") {
|
||||||
|
window.cancelAnimationFrame(rafRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
positionRef,
|
||||||
|
preventLayoutShift(callback: () => void) {
|
||||||
|
if (!positionRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialTop = positionRef.current.getBoundingClientRect().top;
|
||||||
|
|
||||||
|
callback();
|
||||||
|
|
||||||
|
rafRef.current = window.requestAnimationFrame(() => {
|
||||||
|
const newTop =
|
||||||
|
positionRef.current?.getBoundingClientRect().top ?? initialTop;
|
||||||
|
window.scrollBy(0, newTop - initialTop);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const usePreferredLanguageStore = create<{
|
||||||
|
preferredLanguages: Array<string>;
|
||||||
|
addPreferredLanguage: (language: string) => void;
|
||||||
|
}>()((set) => ({
|
||||||
|
preferredLanguages: [],
|
||||||
|
addPreferredLanguage: (language) =>
|
||||||
|
set((state) => ({
|
||||||
|
preferredLanguages: [
|
||||||
|
...state.preferredLanguages.filter(
|
||||||
|
(preferredLanguage) => preferredLanguage !== language
|
||||||
|
),
|
||||||
|
language,
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function useTabGroupProps(availableLanguages: Array<string>) {
|
||||||
|
const { preferredLanguages, addPreferredLanguage } =
|
||||||
|
usePreferredLanguageStore();
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const activeLanguage = [...availableLanguages].sort(
|
||||||
|
(a, z) => preferredLanguages.indexOf(z) - preferredLanguages.indexOf(a)
|
||||||
|
)[0];
|
||||||
|
const languageIndex = availableLanguages.indexOf(activeLanguage);
|
||||||
|
const newSelectedIndex = languageIndex === -1 ? selectedIndex : languageIndex;
|
||||||
|
if (newSelectedIndex !== selectedIndex) {
|
||||||
|
setSelectedIndex(newSelectedIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { positionRef, preventLayoutShift } = usePreventLayoutShift();
|
||||||
|
|
||||||
|
return {
|
||||||
|
as: "div" as const,
|
||||||
|
ref: positionRef,
|
||||||
|
selectedIndex,
|
||||||
|
onChange: (newSelectedIndex: number) => {
|
||||||
|
preventLayoutShift(() =>
|
||||||
|
addPreferredLanguage(availableLanguages[newSelectedIndex])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodeGroupContext = createContext(false);
|
||||||
|
|
||||||
|
export function CodeGroup({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<typeof CodeGroupPanels> & { title: string }) {
|
||||||
|
const languages =
|
||||||
|
Children.map(children, (child) =>
|
||||||
|
getPanelTitle(isValidElement(child) ? child.props : {})
|
||||||
|
) ?? [];
|
||||||
|
const tabGroupProps = useTabGroupProps(languages);
|
||||||
|
const hasTabs = Children.count(children) > 1;
|
||||||
|
|
||||||
|
const containerClassName =
|
||||||
|
"my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10";
|
||||||
|
const header = (
|
||||||
|
<CodeGroupHeader title={title} selectedIndex={tabGroupProps.selectedIndex}>
|
||||||
|
{children}
|
||||||
|
</CodeGroupHeader>
|
||||||
|
);
|
||||||
|
const panels = <CodeGroupPanels {...props}>{children}</CodeGroupPanels>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeGroupContext.Provider value={true}>
|
||||||
|
{hasTabs ? (
|
||||||
|
<Tab.Group {...tabGroupProps} className={containerClassName}>
|
||||||
|
<div className="not-prose">
|
||||||
|
{header}
|
||||||
|
{panels}
|
||||||
|
</div>
|
||||||
|
</Tab.Group>
|
||||||
|
) : (
|
||||||
|
<div className={containerClassName}>
|
||||||
|
<div className="not-prose">
|
||||||
|
{header}
|
||||||
|
{panels}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CodeGroupContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Code({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<"code">) {
|
||||||
|
const isGrouped = useContext(CodeGroupContext);
|
||||||
|
|
||||||
|
if (isGrouped) {
|
||||||
|
if (typeof children !== "string") {
|
||||||
|
throw new Error(
|
||||||
|
"`Code` children must be a string when nested inside a `CodeGroup`."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <code {...props} dangerouslySetInnerHTML={{ __html: children }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <code {...props}>{children}</code>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pre({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<typeof CodeGroup>) {
|
||||||
|
const isGrouped = useContext(CodeGroupContext);
|
||||||
|
|
||||||
|
if (isGrouped) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CodeGroup {...props}>{children}</CodeGroup>;
|
||||||
|
}
|
105
web/src/components/docs/Feedback.tsx
Normal file
105
web/src/components/docs/Feedback.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { forwardRef, Fragment, useState } from 'react'
|
||||||
|
import { Transition } from '@headlessui/react'
|
||||||
|
|
||||||
|
function CheckIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<circle cx="10" cy="10" r="10" strokeWidth="0" />
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
d="m6.75 10.813 2.438 2.437c1.218-4.469 4.062-6.5 4.062-6.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedbackButton(
|
||||||
|
props: Omit<React.ComponentPropsWithoutRef<'button'>, 'type' | 'className'>,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-3 text-sm font-medium text-zinc-600 transition hover:bg-zinc-900/2.5 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-white/5 dark:hover:text-white"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeedbackForm = forwardRef<
|
||||||
|
React.ElementRef<'form'>,
|
||||||
|
Pick<React.ComponentPropsWithoutRef<'form'>, 'onSubmit'>
|
||||||
|
>(function FeedbackForm({ onSubmit }, ref) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
ref={ref}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="absolute inset-0 flex items-center justify-center gap-6 md:justify-start"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
Was this page helpful?
|
||||||
|
</p>
|
||||||
|
<div className="group grid h-8 grid-cols-[1fr,1px,1fr] overflow-hidden rounded-full border border-zinc-900/10 dark:border-white/10">
|
||||||
|
<FeedbackButton data-response="yes">Yes</FeedbackButton>
|
||||||
|
<div className="bg-zinc-900/10 dark:bg-white/10" />
|
||||||
|
<FeedbackButton data-response="no">No</FeedbackButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const FeedbackThanks = forwardRef<React.ElementRef<'div'>>(
|
||||||
|
function FeedbackThanks(_props, ref) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="absolute inset-0 flex justify-center md:justify-start"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 rounded-full bg-emerald-50/50 py-1 pl-1.5 pr-3 text-sm text-emerald-900 ring-1 ring-inset ring-emerald-500/20 dark:bg-emerald-500/5 dark:text-emerald-200 dark:ring-emerald-500/30">
|
||||||
|
<CheckIcon className="h-5 w-5 flex-none fill-emerald-500 stroke-white dark:fill-emerald-200/20 dark:stroke-emerald-200" />
|
||||||
|
Thanks for your feedback!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export function Feedback() {
|
||||||
|
let [submitted, setSubmitted] = useState(false)
|
||||||
|
|
||||||
|
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
// event.nativeEvent.submitter.dataset.response
|
||||||
|
// => "yes" or "no"
|
||||||
|
|
||||||
|
setSubmitted(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-8">
|
||||||
|
<Transition
|
||||||
|
show={!submitted}
|
||||||
|
as={Fragment}
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
leave="pointer-events-none duration-300"
|
||||||
|
>
|
||||||
|
<FeedbackForm onSubmit={onSubmit} />
|
||||||
|
</Transition>
|
||||||
|
<Transition
|
||||||
|
show={submitted}
|
||||||
|
as={Fragment}
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
enter="delay-150 duration-300"
|
||||||
|
>
|
||||||
|
<FeedbackThanks />
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
144
web/src/components/docs/Footer.tsx
Normal file
144
web/src/components/docs/Footer.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/docs/Button";
|
||||||
|
import { navigation } from "@/components/docs/Navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
function PageLink({
|
||||||
|
label,
|
||||||
|
page,
|
||||||
|
previous = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
page: { href: string; title: string };
|
||||||
|
previous?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
href={page.href}
|
||||||
|
aria-label={`${label}: ${page.title}`}
|
||||||
|
variant="secondary"
|
||||||
|
arrow={previous ? "left" : "right"}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
<Link
|
||||||
|
href={page.href}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="text-base font-semibold text-zinc-900 transition hover:text-zinc-600 dark:text-white dark:hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
{page.title}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageNavigation() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const allPages = navigation.flatMap((group) => group.links);
|
||||||
|
const currentPageIndex = allPages.findIndex((page) => page.href === pathname);
|
||||||
|
|
||||||
|
if (currentPageIndex === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousPage = allPages[currentPageIndex - 1];
|
||||||
|
const nextPage = allPages[currentPageIndex + 1];
|
||||||
|
|
||||||
|
if (!previousPage && !nextPage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex">
|
||||||
|
{previousPage && (
|
||||||
|
<div className="flex flex-col items-start gap-3">
|
||||||
|
<PageLink label="Previous" page={previousPage} previous />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{nextPage && (
|
||||||
|
<div className="ml-auto flex flex-col items-end gap-3">
|
||||||
|
<PageLink label="Next" page={nextPage} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TwitterIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path d="M16.712 6.652c.01.146.01.29.01.436 0 4.449-3.267 9.579-9.242 9.579v-.003a8.963 8.963 0 0 1-4.98-1.509 6.379 6.379 0 0 0 4.807-1.396c-1.39-.027-2.608-.966-3.035-2.337.487.097.99.077 1.467-.059-1.514-.316-2.606-1.696-2.606-3.3v-.041c.45.26.956.404 1.475.42C3.18 7.454 2.74 5.486 3.602 3.947c1.65 2.104 4.083 3.382 6.695 3.517a3.446 3.446 0 0 1 .94-3.217 3.172 3.172 0 0 1 4.596.148 6.38 6.38 0 0 0 2.063-.817 3.357 3.357 0 0 1-1.428 1.861 6.283 6.283 0 0 0 1.865-.53 6.735 6.735 0 0 1-1.62 1.744Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GitHubIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M10 1.667c-4.605 0-8.334 3.823-8.334 8.544 0 3.78 2.385 6.974 5.698 8.106.417.075.573-.182.573-.406 0-.203-.011-.875-.011-1.592-2.093.397-2.635-.522-2.802-1.002-.094-.246-.5-1.005-.854-1.207-.291-.16-.708-.556-.01-.567.656-.01 1.124.62 1.281.876.75 1.292 1.948.93 2.427.705.073-.555.291-.93.531-1.143-1.854-.213-3.791-.95-3.791-4.218 0-.929.322-1.698.854-2.296-.083-.214-.375-1.09.083-2.265 0 0 .698-.224 2.292.876a7.576 7.576 0 0 1 2.083-.288c.709 0 1.417.096 2.084.288 1.593-1.11 2.291-.875 2.291-.875.459 1.174.167 2.05.084 2.263.53.599.854 1.357.854 2.297 0 3.278-1.948 4.005-3.802 4.219.302.266.563.78.563 1.58 0 1.143-.011 2.061-.011 2.35 0 .224.156.491.573.405a8.365 8.365 0 0 0 4.11-3.116 8.707 8.707 0 0 0 1.567-4.99c0-4.721-3.73-8.545-8.334-8.545Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiscordIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path d="M16.238 4.515a14.842 14.842 0 0 0-3.664-1.136.055.055 0 0 0-.059.027 10.35 10.35 0 0 0-.456.938 13.702 13.702 0 0 0-4.115 0 9.479 9.479 0 0 0-.464-.938.058.058 0 0 0-.058-.027c-1.266.218-2.497.6-3.664 1.136a.052.052 0 0 0-.024.02C1.4 8.023.76 11.424 1.074 14.782a.062.062 0 0 0 .024.042 14.923 14.923 0 0 0 4.494 2.272.058.058 0 0 0 .064-.02c.346-.473.654-.972.92-1.496a.057.057 0 0 0-.032-.08 9.83 9.83 0 0 1-1.404-.669.058.058 0 0 1-.029-.046.058.058 0 0 1 .023-.05c.094-.07.189-.144.279-.218a.056.056 0 0 1 .058-.008c2.946 1.345 6.135 1.345 9.046 0a.056.056 0 0 1 .059.007c.09.074.184.149.28.22a.058.058 0 0 1 .023.049.059.059 0 0 1-.028.046 9.224 9.224 0 0 1-1.405.669.058.058 0 0 0-.033.033.056.056 0 0 0 .002.047c.27.523.58 1.022.92 1.495a.056.056 0 0 0 .062.021 14.878 14.878 0 0 0 4.502-2.272.055.055 0 0 0 .016-.018.056.056 0 0 0 .008-.023c.375-3.883-.63-7.256-2.662-10.246a.046.046 0 0 0-.023-.021Zm-9.223 8.221c-.887 0-1.618-.814-1.618-1.814s.717-1.814 1.618-1.814c.908 0 1.632.821 1.618 1.814 0 1-.717 1.814-1.618 1.814Zm5.981 0c-.887 0-1.618-.814-1.618-1.814s.717-1.814 1.618-1.814c.908 0 1.632.821 1.618 1.814 0 1-.71 1.814-1.618 1.814Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SocialLink({
|
||||||
|
href,
|
||||||
|
icon: Icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className="group">
|
||||||
|
<span className="sr-only">{children}</span>
|
||||||
|
<Icon className="h-5 w-5 fill-zinc-700 transition group-hover:fill-zinc-900 dark:group-hover:fill-zinc-500" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SmallPrint() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-between gap-5 border-t border-zinc-900/5 pt-8 dark:border-white/5 sm:flex-row">
|
||||||
|
<p className="text-xs text-zinc-600 dark:text-zinc-400">
|
||||||
|
© Copyright {new Date().getFullYear()}. All rights reserved.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<SocialLink href="#" icon={TwitterIcon}>
|
||||||
|
Follow us on Twitter
|
||||||
|
</SocialLink>
|
||||||
|
<SocialLink href="#" icon={GitHubIcon}>
|
||||||
|
Follow us on GitHub
|
||||||
|
</SocialLink>
|
||||||
|
<SocialLink href="#" icon={DiscordIcon}>
|
||||||
|
Join our Discord server
|
||||||
|
</SocialLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="mx-auto w-full max-w-2xl space-y-10 pb-16 lg:max-w-5xl">
|
||||||
|
<PageNavigation />
|
||||||
|
<SmallPrint />
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
55
web/src/components/docs/GridPattern.tsx
Normal file
55
web/src/components/docs/GridPattern.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { useId } from "react";
|
||||||
|
|
||||||
|
export function GridPattern({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
squares,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<"svg"> & {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
x: string | number;
|
||||||
|
y: string | number;
|
||||||
|
squares: Array<[x: number, y: number]>;
|
||||||
|
}) {
|
||||||
|
const patternId = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" {...props}>
|
||||||
|
<defs>
|
||||||
|
<pattern
|
||||||
|
id={patternId}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
>
|
||||||
|
<path d={`M.5 ${height}V.5H${width}`} fill="none" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
strokeWidth={0}
|
||||||
|
fill={`url(#${patternId})`}
|
||||||
|
/>
|
||||||
|
{squares && (
|
||||||
|
<svg x={x} y={y} className="overflow-visible">
|
||||||
|
{squares.map(([x, y]) => (
|
||||||
|
<rect
|
||||||
|
strokeWidth="0"
|
||||||
|
key={`${x}-${y}`}
|
||||||
|
width={width + 1}
|
||||||
|
height={height + 1}
|
||||||
|
x={x * width}
|
||||||
|
y={y * height}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
54
web/src/components/docs/Guides.tsx
Normal file
54
web/src/components/docs/Guides.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { Button } from "@/components/docs/Button";
|
||||||
|
import { Heading } from "@/components/docs/Heading";
|
||||||
|
|
||||||
|
const guides = [
|
||||||
|
{
|
||||||
|
href: "/authentication",
|
||||||
|
name: "Authentication",
|
||||||
|
description: "Learn how to authenticate your API requests.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/pagination",
|
||||||
|
name: "Pagination",
|
||||||
|
description: "Understand how to work with paginated responses.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/errors",
|
||||||
|
name: "Errors",
|
||||||
|
description:
|
||||||
|
"Read about the different types of errors returned by the API.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/webhooks",
|
||||||
|
name: "Webhooks",
|
||||||
|
description:
|
||||||
|
"Learn how to programmatically configure webhooks for your app.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Guides() {
|
||||||
|
return (
|
||||||
|
<div className="my-16 xl:max-w-none">
|
||||||
|
<Heading level={2} id="guides">
|
||||||
|
Guides
|
||||||
|
</Heading>
|
||||||
|
<div className="not-prose mt-4 grid grid-cols-1 gap-8 border-t border-zinc-900/5 pt-10 dark:border-white/5 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{guides.map((guide) => (
|
||||||
|
<div key={guide.href}>
|
||||||
|
<h3 className="text-sm font-semibold text-zinc-900 dark:text-white">
|
||||||
|
{guide.name}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
{guide.description}
|
||||||
|
</p>
|
||||||
|
<p className="mt-4">
|
||||||
|
<Button href={guide.href} variant="text" arrow="right">
|
||||||
|
Read more
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
99
web/src/components/docs/Header.tsx
Normal file
99
web/src/components/docs/Header.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import {
|
||||||
|
MobileNavigation,
|
||||||
|
useIsInsideMobileNavigation,
|
||||||
|
} from "@/components/docs/MobileNavigation";
|
||||||
|
import { useMobileNavigationStore } from "@/components/docs/MobileNavigation";
|
||||||
|
import { MobileSearch, Search } from "@/components/docs/Search";
|
||||||
|
import { ThemeToggle } from "@/components/docs/ThemeToggle";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { motion, useScroll, useTransform } from "framer-motion";
|
||||||
|
import meta from "next-gen/config";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { forwardRef } from "react";
|
||||||
|
|
||||||
|
function TopLevelNavItem({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="text-sm leading-5 text-zinc-600 transition hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Header = forwardRef<
|
||||||
|
React.ElementRef<"div">,
|
||||||
|
{ className?: string }
|
||||||
|
>(function Header({ className }, ref) {
|
||||||
|
const { isOpen: mobileNavIsOpen } = useMobileNavigationStore();
|
||||||
|
const isInsideMobileNavigation = useIsInsideMobileNavigation();
|
||||||
|
|
||||||
|
const { scrollY } = useScroll();
|
||||||
|
const bgOpacityLight = useTransform(scrollY, [0, 72], [0.5, 0.9]);
|
||||||
|
const bgOpacityDark = useTransform(scrollY, [0, 72], [0.2, 0.8]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
"fixed inset-x-0 top-0 z-50 flex h-14 items-center justify-between gap-12 px-4 transition sm:px-6 lg:left-72 lg:z-30 lg:px-8 xl:left-80",
|
||||||
|
!isInsideMobileNavigation &&
|
||||||
|
"backdrop-blur-sm dark:backdrop-blur lg:left-72 xl:left-80",
|
||||||
|
isInsideMobileNavigation
|
||||||
|
? "bg-white dark:bg-zinc-900"
|
||||||
|
: "bg-white/[var(--bg-opacity-light)] dark:bg-zinc-900/[var(--bg-opacity-dark)]"
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--bg-opacity-light": bgOpacityLight,
|
||||||
|
"--bg-opacity-dark": bgOpacityDark,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"absolute inset-x-0 top-full h-px transition",
|
||||||
|
(isInsideMobileNavigation || !mobileNavIsOpen) &&
|
||||||
|
"bg-zinc-900/7.5 dark:bg-white/7.5"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Search />
|
||||||
|
<div className="flex items-center gap-5 lg:hidden">
|
||||||
|
<MobileNavigation />
|
||||||
|
{/* <Link href="/" aria-label="Home">
|
||||||
|
<Logo className="h-6" />
|
||||||
|
</Link> */}
|
||||||
|
<a className="font-bold text-md md:text-lg hover:underline" href="/">
|
||||||
|
{meta.name}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
{/* <nav className="hidden md:block">
|
||||||
|
<ul role="list" className="flex items-center gap-8">
|
||||||
|
<TopLevelNavItem href="/">API</TopLevelNavItem>
|
||||||
|
<TopLevelNavItem href="#">Documentation</TopLevelNavItem>
|
||||||
|
<TopLevelNavItem href="#">Support</TopLevelNavItem>
|
||||||
|
</ul>
|
||||||
|
</nav> */}
|
||||||
|
<div className="hidden md:block md:h-5 md:w-px md:bg-zinc-900/10 md:dark:bg-white/15" />
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<MobileSearch />
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
{/* <div className="hidden min-[416px]:contents">
|
||||||
|
<Button href="#">Sign in</Button>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
});
|
116
web/src/components/docs/Heading.tsx
Normal file
116
web/src/components/docs/Heading.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSectionStore } from "@/components/docs/SectionProvider";
|
||||||
|
import { Tag } from "@/components/docs/Tag";
|
||||||
|
import { remToPx } from "@/lib/remToPx";
|
||||||
|
import { useInView } from "framer-motion";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
function AnchorIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="m6.5 11.5-.964-.964a3.535 3.535 0 1 1 5-5l.964.964m2 2 .964.964a3.536 3.536 0 0 1-5 5L8.5 13.5m0-5 3 3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Eyebrow({ tag, label }: { tag?: string; label?: string }) {
|
||||||
|
if (!tag && !label) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-x-3">
|
||||||
|
{tag && <Tag>{tag}</Tag>}
|
||||||
|
{tag && label && (
|
||||||
|
<span className="h-0.5 w-0.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||||
|
)}
|
||||||
|
{label && (
|
||||||
|
<span className="font-mono text-xs text-zinc-400">{label}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Anchor({
|
||||||
|
id,
|
||||||
|
inView,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
inView: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`#${id}`}
|
||||||
|
className="group text-inherit no-underline hover:text-inherit"
|
||||||
|
>
|
||||||
|
{inView && (
|
||||||
|
<div className="absolute ml-[calc(-1*var(--width))] mt-1 hidden w-[var(--width)] opacity-0 transition [--width:calc(2.625rem+0.5px+50%-min(50%,calc(theme(maxWidth.lg)+theme(spacing.8))))] group-hover:opacity-100 group-focus:opacity-100 md:block lg:z-50 2xl:[--width:theme(spacing.10)]">
|
||||||
|
<div className="group/anchor block h-5 w-5 rounded-lg bg-zinc-50 ring-1 ring-inset ring-zinc-300 transition hover:ring-zinc-500 dark:bg-zinc-800 dark:ring-zinc-700 dark:hover:bg-zinc-700 dark:hover:ring-zinc-600">
|
||||||
|
<AnchorIcon className="h-5 w-5 stroke-zinc-500 transition dark:stroke-zinc-400 dark:group-hover/anchor:stroke-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Heading<Level extends 2 | 3>({
|
||||||
|
children,
|
||||||
|
tag,
|
||||||
|
label,
|
||||||
|
level,
|
||||||
|
anchor = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<`h${Level}`> & {
|
||||||
|
id: string;
|
||||||
|
tag?: string;
|
||||||
|
label?: string;
|
||||||
|
level?: Level;
|
||||||
|
anchor?: boolean;
|
||||||
|
}) {
|
||||||
|
level = level ?? (2 as Level);
|
||||||
|
const Component = `h${level}` as "h2" | "h3";
|
||||||
|
const ref = useRef<HTMLHeadingElement>(null);
|
||||||
|
const registerHeading = useSectionStore((s) => s.registerHeading);
|
||||||
|
|
||||||
|
const inView = useInView(ref, {
|
||||||
|
margin: `${remToPx(-3.5)}px 0px 0px 0px`,
|
||||||
|
amount: "all",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (level === 2) {
|
||||||
|
registerHeading({ id: props.id, ref, offsetRem: tag || label ? 8 : 6 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Eyebrow tag={tag} label={label} />
|
||||||
|
<Component
|
||||||
|
ref={ref}
|
||||||
|
className={tag || label ? "mt-2 scroll-mt-32" : "scroll-mt-24"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{anchor ? (
|
||||||
|
<Anchor id={props.id} inView={inView}>
|
||||||
|
{children}
|
||||||
|
</Anchor>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</Component>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
32
web/src/components/docs/HeroPattern.tsx
Normal file
32
web/src/components/docs/HeroPattern.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { GridPattern } from "@/components/docs/GridPattern";
|
||||||
|
|
||||||
|
export function HeroPattern() {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 -z-10 mx-0 max-w-none overflow-hidden">
|
||||||
|
<div className="absolute left-1/2 top-0 ml-[-38rem] h-[25rem] w-[81.25rem] dark:[mask-image:linear-gradient(white,transparent)]">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-[#36b49f] to-[#DBFF75] opacity-40 [mask-image:radial-gradient(farthest-side_at_top,white,transparent)] dark:from-[#36b49f]/30 dark:to-[#DBFF75]/30 dark:opacity-100">
|
||||||
|
<GridPattern
|
||||||
|
width={72}
|
||||||
|
height={56}
|
||||||
|
x={-12}
|
||||||
|
y={4}
|
||||||
|
squares={[
|
||||||
|
[4, 3],
|
||||||
|
[2, 1],
|
||||||
|
[7, 3],
|
||||||
|
[10, 6],
|
||||||
|
]}
|
||||||
|
className="absolute inset-x-0 inset-y-[-50%] h-[200%] w-full skew-y-[-18deg] fill-black/40 stroke-black/50 mix-blend-overlay dark:fill-white/2.5 dark:stroke-white/5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 1113 440"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute left-1/2 top-0 ml-[-19rem] w-[69.5625rem] fill-white blur-[26px] dark:hidden"
|
||||||
|
>
|
||||||
|
<path d="M.016 439.5s-9.5-300 434-300S882.516 20 882.516 20V0h230.004v439.5H.016Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
52
web/src/components/docs/Layout.tsx
Normal file
52
web/src/components/docs/Layout.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Footer } from "@/components/docs/Footer";
|
||||||
|
import { Header } from "@/components/docs/Header";
|
||||||
|
import { Navigation } from "@/components/docs/Navigation";
|
||||||
|
import {
|
||||||
|
type Section,
|
||||||
|
SectionProvider,
|
||||||
|
} from "@/components/docs/SectionProvider";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import meta from "next-gen/config";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
export function Layout({
|
||||||
|
children,
|
||||||
|
allSections,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
allSections: Record<string, Array<Section>>;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionProvider sections={allSections[pathname] ?? []}>
|
||||||
|
<div className="h-full lg:ml-72 xl:ml-80">
|
||||||
|
<motion.header
|
||||||
|
layoutScroll
|
||||||
|
className="contents lg:pointer-events-none lg:fixed lg:inset-0 lg:z-40 lg:flex"
|
||||||
|
>
|
||||||
|
<div className="contents lg:pointer-events-auto lg:block lg:w-72 lg:overflow-y-auto lg:border-r lg:border-zinc-900/10 lg:px-6 lg:pb-8 lg:pt-4 lg:dark:border-white/10 xl:w-80">
|
||||||
|
<div className="hidden lg:flex">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="font-bold text-md md:text-lg hover:underline"
|
||||||
|
aria-label="Home"
|
||||||
|
>
|
||||||
|
{meta.name}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Header />
|
||||||
|
<Navigation className="hidden lg:mt-10 lg:block" />
|
||||||
|
</div>
|
||||||
|
</motion.header>
|
||||||
|
<div className="relative flex h-full flex-col px-4 pt-14 sm:px-6 lg:px-8">
|
||||||
|
<main className="flex-auto">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionProvider>
|
||||||
|
);
|
||||||
|
}
|
81
web/src/components/docs/Libraries.tsx
Normal file
81
web/src/components/docs/Libraries.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { Button } from "@/components/docs/Button";
|
||||||
|
import { Heading } from "@/components/docs/Heading";
|
||||||
|
import logoGo from "@/images/logos/go.svg";
|
||||||
|
import logoNode from "@/images/logos/node.svg";
|
||||||
|
import logoPhp from "@/images/logos/php.svg";
|
||||||
|
import logoPython from "@/images/logos/python.svg";
|
||||||
|
import logoRuby from "@/images/logos/ruby.svg";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
const libraries = [
|
||||||
|
{
|
||||||
|
href: "#",
|
||||||
|
name: "PHP",
|
||||||
|
description:
|
||||||
|
"A popular general-purpose scripting language that is especially suited to web development.",
|
||||||
|
logo: logoPhp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "#",
|
||||||
|
name: "Ruby",
|
||||||
|
description:
|
||||||
|
"A dynamic, open source programming language with a focus on simplicity and productivity.",
|
||||||
|
logo: logoRuby,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "#",
|
||||||
|
name: "Node.js",
|
||||||
|
description:
|
||||||
|
"Node.js® is an open-source, cross-platform JavaScript runtime environment.",
|
||||||
|
logo: logoNode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "#",
|
||||||
|
name: "Python",
|
||||||
|
description:
|
||||||
|
"Python is a programming language that lets you work quickly and integrate systems more effectively.",
|
||||||
|
logo: logoPython,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "#",
|
||||||
|
name: "Go",
|
||||||
|
description:
|
||||||
|
"An open-source programming language supported by Google with built-in concurrency.",
|
||||||
|
logo: logoGo,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Libraries() {
|
||||||
|
return (
|
||||||
|
<div className="my-16 xl:max-w-none">
|
||||||
|
<Heading level={2} id="official-libraries">
|
||||||
|
Official libraries
|
||||||
|
</Heading>
|
||||||
|
<div className="not-prose mt-4 grid grid-cols-1 gap-x-6 gap-y-10 border-t border-zinc-900/5 pt-10 dark:border-white/5 sm:grid-cols-2 xl:max-w-none xl:grid-cols-3">
|
||||||
|
{libraries.map((library) => (
|
||||||
|
<div key={library.name} className="flex flex-row-reverse gap-6">
|
||||||
|
<div className="flex-auto">
|
||||||
|
<h3 className="text-sm font-semibold text-zinc-900 dark:text-white">
|
||||||
|
{library.name}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
{library.description}
|
||||||
|
</p>
|
||||||
|
<p className="mt-4">
|
||||||
|
<Button href={library.href} variant="text" arrow="right">
|
||||||
|
Read more
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Image
|
||||||
|
src={library.logo}
|
||||||
|
alt=""
|
||||||
|
className="h-12 w-12"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
14
web/src/components/docs/Logo.tsx
Normal file
14
web/src/components/docs/Logo.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export function Logo(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 99 24" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
className="fill-emerald-400"
|
||||||
|
d="M16 8a5 5 0 0 0-5-5H5a5 5 0 0 0-5 5v13.927a1 1 0 0 0 1.623.782l3.684-2.93a4 4 0 0 1 2.49-.87H11a5 5 0 0 0 5-5V8Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="fill-zinc-900 dark:fill-white"
|
||||||
|
d="M26.538 18h2.654v-3.999h2.576c2.672 0 4.456-1.723 4.456-4.333V9.65c0-2.61-1.784-4.333-4.456-4.333h-5.23V18Zm4.58-10.582c1.52 0 2.416.8 2.416 2.241v.018c0 1.441-.896 2.25-2.417 2.25h-1.925V7.418h1.925ZM38.051 18h2.566v-5.414c0-1.371.923-2.206 2.382-2.206.396 0 .791.061 1.178.15V8.287a3.843 3.843 0 0 0-.958-.123c-1.257 0-2.136.615-2.443 1.661h-.159V8.323h-2.566V18Zm11.55.202c2.979 0 4.772-1.88 4.772-5.036v-.018c0-3.128-1.82-5.036-4.773-5.036-2.953 0-4.772 1.916-4.772 5.036v.018c0 3.146 1.793 5.036 4.772 5.036Zm0-2.013c-1.372 0-2.145-1.116-2.145-3.023v-.018c0-1.89.782-3.023 2.144-3.023 1.354 0 2.145 1.134 2.145 3.023v.018c0 1.907-.782 3.023-2.145 3.023Zm10.52 1.846c.492 0 .967-.053 1.283-.114v-1.907a6.057 6.057 0 0 1-.755.044c-.87 0-1.24-.387-1.24-1.257v-4.544h1.995V8.323H59.41V6.012h-2.592v2.311h-1.495v1.934h1.495v5.133c0 1.88.949 2.645 3.304 2.645Zm7.287.167c2.98 0 4.772-1.88 4.772-5.036v-.018c0-3.128-1.82-5.036-4.772-5.036-2.954 0-4.773 1.916-4.773 5.036v.018c0 3.146 1.793 5.036 4.773 5.036Zm0-2.013c-1.372 0-2.145-1.116-2.145-3.023v-.018c0-1.89.782-3.023 2.145-3.023 1.353 0 2.144 1.134 2.144 3.023v.018c0 1.907-.782 3.023-2.144 3.023Zm10.767 2.013c2.522 0 4.034-1.353 4.297-3.463l.01-.053h-2.374l-.017.036c-.229.966-.853 1.467-1.908 1.467-1.37 0-2.135-1.08-2.135-3.04v-.018c0-1.934.755-3.006 2.135-3.006 1.099 0 1.74.615 1.908 1.556l.008.017h2.391v-.026c-.228-2.162-1.749-3.56-4.315-3.56-3.033 0-4.738 1.837-4.738 5.019v.017c0 3.217 1.714 5.054 4.738 5.054Zm10.257 0c2.98 0 4.772-1.88 4.772-5.036v-.018c0-3.128-1.82-5.036-4.772-5.036-2.953 0-4.773 1.916-4.773 5.036v.018c0 3.146 1.793 5.036 4.773 5.036Zm0-2.013c-1.371 0-2.145-1.116-2.145-3.023v-.018c0-1.89.782-3.023 2.145-3.023 1.353 0 2.144 1.134 2.144 3.023v.018c0 1.907-.782 3.023-2.144 3.023ZM95.025 18h2.566V4.623h-2.566V18Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
173
web/src/components/docs/MobileNavigation.tsx
Normal file
173
web/src/components/docs/MobileNavigation.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Header } from "@/components/docs/Header";
|
||||||
|
import { Navigation } from "@/components/docs/Navigation";
|
||||||
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
Fragment,
|
||||||
|
Suspense,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
function MenuIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 10 9"
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M.5 1h9M.5 8h9M.5 4.5h9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function XIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 10 9"
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="m1.5 1 7 7M8.5 1l-7 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const IsInsideMobileNavigationContext = createContext(false);
|
||||||
|
|
||||||
|
function MobileNavigationDialog({
|
||||||
|
isOpen,
|
||||||
|
close,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
close: () => void;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const initialPathname = useRef(pathname).current;
|
||||||
|
const initialSearchParams = useRef(searchParams).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pathname !== initialPathname || searchParams !== initialSearchParams) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}, [pathname, searchParams, close, initialPathname, initialSearchParams]);
|
||||||
|
|
||||||
|
function onClickDialog(event: React.MouseEvent<HTMLDivElement>) {
|
||||||
|
if (!(event.target instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = event.target.closest("a");
|
||||||
|
if (
|
||||||
|
link &&
|
||||||
|
link.pathname + link.search + link.hash ===
|
||||||
|
window.location.pathname + window.location.search + window.location.hash
|
||||||
|
) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition.Root show={isOpen} as={Fragment}>
|
||||||
|
<Dialog
|
||||||
|
onClickCapture={onClickDialog}
|
||||||
|
onClose={close}
|
||||||
|
className="fixed inset-0 z-50 lg:hidden"
|
||||||
|
>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="duration-300 ease-out"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="duration-200 ease-in"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 top-14 bg-zinc-400/20 backdrop-blur-sm dark:bg-black/40" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<Dialog.Panel>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="duration-300 ease-out"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="duration-200 ease-in"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="duration-500 ease-in-out"
|
||||||
|
enterFrom="-translate-x-full"
|
||||||
|
enterTo="translate-x-0"
|
||||||
|
leave="duration-500 ease-in-out"
|
||||||
|
leaveFrom="translate-x-0"
|
||||||
|
leaveTo="-translate-x-full"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
layoutScroll
|
||||||
|
className="fixed bottom-0 left-0 top-14 w-full overflow-y-auto bg-white px-4 pb-4 pt-6 shadow-lg shadow-zinc-900/10 ring-1 ring-zinc-900/7.5 dark:bg-zinc-900 dark:ring-zinc-800 min-[416px]:max-w-sm sm:px-6 sm:pb-10"
|
||||||
|
>
|
||||||
|
<Navigation />
|
||||||
|
</motion.div>
|
||||||
|
</Transition.Child>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIsInsideMobileNavigation() {
|
||||||
|
return useContext(IsInsideMobileNavigationContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMobileNavigationStore = create<{
|
||||||
|
isOpen: boolean;
|
||||||
|
open: () => void;
|
||||||
|
close: () => void;
|
||||||
|
toggle: () => void;
|
||||||
|
}>()((set) => ({
|
||||||
|
isOpen: false,
|
||||||
|
open: () => set({ isOpen: true }),
|
||||||
|
close: () => set({ isOpen: false }),
|
||||||
|
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function MobileNavigation() {
|
||||||
|
const isInsideMobileNavigation = useIsInsideMobileNavigation();
|
||||||
|
const { isOpen, toggle, close } = useMobileNavigationStore();
|
||||||
|
const ToggleIcon = isOpen ? XIcon : MenuIcon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IsInsideMobileNavigationContext.Provider value={true}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md transition hover:bg-zinc-900/5 dark:hover:bg-white/5"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
<ToggleIcon className="w-2.5 stroke-zinc-900 dark:stroke-white" />
|
||||||
|
</button>
|
||||||
|
{!isInsideMobileNavigation && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<MobileNavigationDialog isOpen={isOpen} close={close} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</IsInsideMobileNavigationContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
265
web/src/components/docs/Navigation.tsx
Normal file
265
web/src/components/docs/Navigation.tsx
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/docs/Button";
|
||||||
|
import { useIsInsideMobileNavigation } from "@/components/docs/MobileNavigation";
|
||||||
|
import { useSectionStore } from "@/components/docs/SectionProvider";
|
||||||
|
import { Tag } from "@/components/docs/Tag";
|
||||||
|
import { remToPx } from "@/lib/remToPx";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { AnimatePresence, motion, useIsPresent } from "framer-motion";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
interface NavGroup {
|
||||||
|
title: string;
|
||||||
|
links: Array<{
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInitialValue<T>(value: T, condition = true) {
|
||||||
|
const initialValue = useRef(value).current;
|
||||||
|
return condition ? initialValue : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopLevelNavItem({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li className="md:hidden">
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="block py-1 text-sm text-zinc-600 transition hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLink({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
tag,
|
||||||
|
active = false,
|
||||||
|
isAnchorLink = false,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
tag?: string;
|
||||||
|
active?: boolean;
|
||||||
|
isAnchorLink?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={clsx(
|
||||||
|
"flex justify-between gap-2 py-1 pr-3 text-sm transition",
|
||||||
|
isAnchorLink ? "pl-7" : "pl-4",
|
||||||
|
active
|
||||||
|
? "text-zinc-900 dark:text-white"
|
||||||
|
: "text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{children}</span>
|
||||||
|
{tag && (
|
||||||
|
<Tag variant="small" color="zinc">
|
||||||
|
{tag}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VisibleSectionHighlight({
|
||||||
|
group,
|
||||||
|
pathname,
|
||||||
|
}: {
|
||||||
|
group: NavGroup;
|
||||||
|
pathname: string;
|
||||||
|
}) {
|
||||||
|
const [sections, visibleSections] = useInitialValue(
|
||||||
|
[
|
||||||
|
useSectionStore((s) => s.sections),
|
||||||
|
useSectionStore((s) => s.visibleSections),
|
||||||
|
],
|
||||||
|
useIsInsideMobileNavigation()
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPresent = useIsPresent();
|
||||||
|
const firstVisibleSectionIndex = Math.max(
|
||||||
|
0,
|
||||||
|
[{ id: "_top" }, ...sections].findIndex(
|
||||||
|
(section) => section.id === visibleSections[0]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const itemHeight = remToPx(2);
|
||||||
|
const height = isPresent
|
||||||
|
? Math.max(1, visibleSections.length) * itemHeight
|
||||||
|
: itemHeight;
|
||||||
|
const top =
|
||||||
|
group.links.findIndex((link) => link.href === pathname) * itemHeight +
|
||||||
|
firstVisibleSectionIndex * itemHeight;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1, transition: { delay: 0.2 } }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute inset-x-0 top-0 bg-zinc-800/2.5 will-change-transform dark:bg-white/2.5"
|
||||||
|
style={{ borderRadius: 8, height, top }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivePageMarker({
|
||||||
|
group,
|
||||||
|
pathname,
|
||||||
|
}: {
|
||||||
|
group: NavGroup;
|
||||||
|
pathname: string;
|
||||||
|
}) {
|
||||||
|
const itemHeight = remToPx(2);
|
||||||
|
const offset = remToPx(0.25);
|
||||||
|
const activePageIndex = group.links.findIndex(
|
||||||
|
(link) => link.href === pathname
|
||||||
|
);
|
||||||
|
const top = offset + activePageIndex * itemHeight;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
className="absolute left-2 h-6 w-px bg-emerald-500"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1, transition: { delay: 0.2 } }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
style={{ top }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationGroup({
|
||||||
|
group,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
group: NavGroup;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
// If this is the mobile navigation then we always render the initial
|
||||||
|
// state, so that the state does not change during the close animation.
|
||||||
|
// The state will still update when we re-open (re-render) the navigation.
|
||||||
|
const isInsideMobileNavigation = useIsInsideMobileNavigation();
|
||||||
|
const [pathname, sections] = useInitialValue(
|
||||||
|
[usePathname(), useSectionStore((s) => s.sections)],
|
||||||
|
isInsideMobileNavigation
|
||||||
|
);
|
||||||
|
|
||||||
|
const isActiveGroup =
|
||||||
|
group.links.findIndex((link) => link.href === pathname) !== -1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={clsx("relative mt-6", className)}>
|
||||||
|
<motion.h2
|
||||||
|
layout="position"
|
||||||
|
className="text-xs font-semibold text-zinc-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{group.title}
|
||||||
|
</motion.h2>
|
||||||
|
<div className="relative mt-3 pl-2">
|
||||||
|
<AnimatePresence initial={!isInsideMobileNavigation}>
|
||||||
|
{isActiveGroup && (
|
||||||
|
<VisibleSectionHighlight group={group} pathname={pathname} />
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
className="absolute inset-y-0 left-2 w-px bg-zinc-900/10 dark:bg-white/5"
|
||||||
|
/>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isActiveGroup && (
|
||||||
|
<ActivePageMarker group={group} pathname={pathname} />
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<ul role="list" className="border-l border-transparent">
|
||||||
|
{group.links.map((link) => (
|
||||||
|
<motion.li key={link.href} layout="position" className="relative">
|
||||||
|
<NavLink href={link.href} active={link.href === pathname}>
|
||||||
|
{link.title}
|
||||||
|
</NavLink>
|
||||||
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
|
{link.href === pathname && sections.length > 0 && (
|
||||||
|
<motion.ul
|
||||||
|
role="list"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
transition: { delay: 0.1 },
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
transition: { duration: 0.15 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sections.map((section) => (
|
||||||
|
<li key={section.id}>
|
||||||
|
<NavLink
|
||||||
|
href={`${link.href}#${section.id}`}
|
||||||
|
tag={section.tag}
|
||||||
|
isAnchorLink
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</motion.ul>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const navigation: Array<NavGroup> = [
|
||||||
|
{
|
||||||
|
title: "Guides",
|
||||||
|
links: [
|
||||||
|
{ title: "Introduction", href: "/docs" },
|
||||||
|
{ title: "Installation", href: "/docs/install" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Navigation(props: React.ComponentPropsWithoutRef<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav {...props}>
|
||||||
|
<ul role="list">
|
||||||
|
<TopLevelNavItem href="/">API</TopLevelNavItem>
|
||||||
|
<TopLevelNavItem href="#">Documentation</TopLevelNavItem>
|
||||||
|
<TopLevelNavItem href="#">Support</TopLevelNavItem>
|
||||||
|
{navigation.map((group, groupIndex) => (
|
||||||
|
<NavigationGroup
|
||||||
|
key={group.title}
|
||||||
|
group={group}
|
||||||
|
className={groupIndex === 0 ? "md:mt-0" : ""}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<li className="sticky bottom-0 z-10 mt-6 min-[416px]:hidden">
|
||||||
|
<Button href="#" variant="filled" className="w-full">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
24
web/src/components/docs/Prose.tsx
Normal file
24
web/src/components/docs/Prose.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export function Prose<T extends React.ElementType = 'div'>({
|
||||||
|
as,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'className'> & {
|
||||||
|
as?: T
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
let Component = as ?? 'div'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'prose dark:prose-invert',
|
||||||
|
// `html :where(& > *)` is used to select all direct children without an increase in specificity like you'd get from just `& > *`
|
||||||
|
'[html_:where(&>*)]:mx-auto [html_:where(&>*)]:max-w-2xl [html_:where(&>*)]:lg:mx-[calc(50%-min(50%,theme(maxWidth.lg)))] [html_:where(&>*)]:lg:max-w-3xl',
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
182
web/src/components/docs/Resources.tsx
Normal file
182
web/src/components/docs/Resources.tsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { GridPattern } from "@/components/docs/GridPattern";
|
||||||
|
import { Heading } from "@/components/docs/Heading";
|
||||||
|
import {
|
||||||
|
type MotionValue,
|
||||||
|
motion,
|
||||||
|
useMotionTemplate,
|
||||||
|
useMotionValue,
|
||||||
|
} from "framer-motion";
|
||||||
|
import { Mail, MessageSquare, User, Users } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface Resource {
|
||||||
|
href: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
pattern: Omit<
|
||||||
|
React.ComponentPropsWithoutRef<typeof GridPattern>,
|
||||||
|
"width" | "height" | "x"
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources: Array<Resource> = [
|
||||||
|
{
|
||||||
|
href: "/contacts",
|
||||||
|
name: "Contacts",
|
||||||
|
description:
|
||||||
|
"Learn about the contact model and how to create, retrieve, update, delete, and list contacts.",
|
||||||
|
icon: User,
|
||||||
|
pattern: {
|
||||||
|
y: 16,
|
||||||
|
squares: [
|
||||||
|
[0, 1],
|
||||||
|
[1, 3],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/conversations",
|
||||||
|
name: "Conversations",
|
||||||
|
description:
|
||||||
|
"Learn about the conversation model and how to create, retrieve, update, delete, and list conversations.",
|
||||||
|
icon: MessageSquare,
|
||||||
|
pattern: {
|
||||||
|
y: -6,
|
||||||
|
squares: [
|
||||||
|
[-1, 2],
|
||||||
|
[1, 3],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/messages",
|
||||||
|
name: "Messages",
|
||||||
|
description:
|
||||||
|
"Learn about the message model and how to create, retrieve, update, delete, and list messages.",
|
||||||
|
icon: Mail,
|
||||||
|
pattern: {
|
||||||
|
y: 32,
|
||||||
|
squares: [
|
||||||
|
[0, 2],
|
||||||
|
[1, 4],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/groups",
|
||||||
|
name: "Groups",
|
||||||
|
description:
|
||||||
|
"Learn about the group model and how to create, retrieve, update, delete, and list groups.",
|
||||||
|
icon: Users,
|
||||||
|
pattern: {
|
||||||
|
y: 22,
|
||||||
|
squares: [[0, 1]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function ResourceIcon({ icon: Icon }: { icon: Resource["icon"] }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-zinc-900/5 ring-1 ring-zinc-900/25 backdrop-blur-[2px] transition duration-300 group-hover:bg-white/50 group-hover:ring-zinc-900/25 dark:bg-white/7.5 dark:ring-white/15 dark:group-hover:bg-emerald-300/10 dark:group-hover:ring-emerald-400">
|
||||||
|
<Icon className="h-5 w-5 fill-zinc-700/10 stroke-zinc-700 transition-colors duration-300 group-hover:stroke-zinc-900 dark:fill-white/10 dark:stroke-zinc-400 dark:group-hover:fill-emerald-300/10 dark:group-hover:stroke-emerald-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourcePattern({
|
||||||
|
mouseX,
|
||||||
|
mouseY,
|
||||||
|
...gridProps
|
||||||
|
}: Resource["pattern"] & {
|
||||||
|
mouseX: MotionValue<number>;
|
||||||
|
mouseY: MotionValue<number>;
|
||||||
|
}) {
|
||||||
|
const maskImage = useMotionTemplate`radial-gradient(180px at ${mouseX}px ${mouseY}px, white, transparent)`;
|
||||||
|
const style = { maskImage, WebkitMaskImage: maskImage };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none">
|
||||||
|
<div className="absolute inset-0 rounded-2xl transition duration-300 [mask-image:linear-gradient(white,transparent)] group-hover:opacity-50">
|
||||||
|
<GridPattern
|
||||||
|
width={72}
|
||||||
|
height={56}
|
||||||
|
x="50%"
|
||||||
|
className="absolute inset-x-0 inset-y-[-30%] h-[160%] w-full skew-y-[-18deg] fill-black/[0.02] stroke-black/5 dark:fill-white/1 dark:stroke-white/2.5"
|
||||||
|
{...gridProps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-[#D7EDEA] to-[#F4FBDF] opacity-0 transition duration-300 group-hover:opacity-100 dark:from-[#202D2E] dark:to-[#303428]"
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 rounded-2xl opacity-0 mix-blend-overlay transition duration-300 group-hover:opacity-100"
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<GridPattern
|
||||||
|
width={72}
|
||||||
|
height={56}
|
||||||
|
x="50%"
|
||||||
|
className="absolute inset-x-0 inset-y-[-30%] h-[160%] w-full skew-y-[-18deg] fill-black/50 stroke-black/70 dark:fill-white/2.5 dark:stroke-white/10"
|
||||||
|
{...gridProps}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resource({ resource }: { resource: Resource }) {
|
||||||
|
const mouseX = useMotionValue(0);
|
||||||
|
const mouseY = useMotionValue(0);
|
||||||
|
|
||||||
|
function onMouseMove({
|
||||||
|
currentTarget,
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
}: React.MouseEvent<HTMLDivElement>) {
|
||||||
|
const { left, top } = currentTarget.getBoundingClientRect();
|
||||||
|
mouseX.set(clientX - left);
|
||||||
|
mouseY.set(clientY - top);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={resource.href}
|
||||||
|
onMouseMove={onMouseMove}
|
||||||
|
className="group relative flex rounded-2xl bg-zinc-50 transition-shadow hover:shadow-md hover:shadow-zinc-900/5 dark:bg-white/2.5 dark:hover:shadow-black/5"
|
||||||
|
>
|
||||||
|
<ResourcePattern {...resource.pattern} mouseX={mouseX} mouseY={mouseY} />
|
||||||
|
<div className="absolute inset-0 rounded-2xl ring-1 ring-inset ring-zinc-900/7.5 group-hover:ring-zinc-900/10 dark:ring-white/10 dark:group-hover:ring-white/20" />
|
||||||
|
<div className="relative rounded-2xl px-4 pb-4 pt-16">
|
||||||
|
<ResourceIcon icon={resource.icon} />
|
||||||
|
<h3 className="mt-4 text-sm font-semibold leading-7 text-zinc-900 dark:text-white">
|
||||||
|
<Link href={resource.href}>
|
||||||
|
<span className="absolute inset-0 rounded-2xl" />
|
||||||
|
{resource.name}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
{resource.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Resources() {
|
||||||
|
return (
|
||||||
|
<div className="my-16 xl:max-w-none">
|
||||||
|
<Heading level={2} id="resources">
|
||||||
|
Resources
|
||||||
|
</Heading>
|
||||||
|
<div className="not-prose mt-4 grid grid-cols-1 gap-8 border-t border-zinc-900/5 pt-10 dark:border-white/5 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{resources.map((resource) => (
|
||||||
|
<Resource key={resource.href} resource={resource} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
504
web/src/components/docs/Search.tsx
Normal file
504
web/src/components/docs/Search.tsx
Normal file
@ -0,0 +1,504 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { navigation } from "@/components/docs/Navigation";
|
||||||
|
import { type Result } from "@/mdx/search.mjs";
|
||||||
|
import {
|
||||||
|
type AutocompleteApi,
|
||||||
|
createAutocomplete,
|
||||||
|
type AutocompleteState,
|
||||||
|
type AutocompleteCollection,
|
||||||
|
} from "@algolia/autocomplete-core";
|
||||||
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
Fragment,
|
||||||
|
Suspense,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import Highlighter from "react-highlight-words";
|
||||||
|
|
||||||
|
type EmptyObject = Record<string, never>;
|
||||||
|
|
||||||
|
type Autocomplete = AutocompleteApi<
|
||||||
|
Result,
|
||||||
|
React.SyntheticEvent,
|
||||||
|
React.MouseEvent,
|
||||||
|
React.KeyboardEvent
|
||||||
|
>;
|
||||||
|
|
||||||
|
function useAutocomplete({ close }: { close: () => void }) {
|
||||||
|
const id = useId();
|
||||||
|
const router = useRouter();
|
||||||
|
const [autocompleteState, setAutocompleteState] = useState<
|
||||||
|
AutocompleteState<Result> | EmptyObject
|
||||||
|
>({});
|
||||||
|
|
||||||
|
function navigate({ itemUrl }: { itemUrl?: string }) {
|
||||||
|
if (!itemUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(itemUrl);
|
||||||
|
|
||||||
|
if (
|
||||||
|
itemUrl ===
|
||||||
|
window.location.pathname + window.location.search + window.location.hash
|
||||||
|
) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [autocomplete] = useState<Autocomplete>(() =>
|
||||||
|
createAutocomplete<
|
||||||
|
Result,
|
||||||
|
React.SyntheticEvent,
|
||||||
|
React.MouseEvent,
|
||||||
|
React.KeyboardEvent
|
||||||
|
>({
|
||||||
|
id,
|
||||||
|
placeholder: "Find something...",
|
||||||
|
defaultActiveItemId: 0,
|
||||||
|
onStateChange({ state }) {
|
||||||
|
setAutocompleteState(state);
|
||||||
|
},
|
||||||
|
shouldPanelOpen({ state }) {
|
||||||
|
return state.query !== "";
|
||||||
|
},
|
||||||
|
navigator: {
|
||||||
|
navigate,
|
||||||
|
},
|
||||||
|
getSources({ query }) {
|
||||||
|
return import("@/mdx/search.mjs").then(({ search }) => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sourceId: "documentation",
|
||||||
|
getItems() {
|
||||||
|
return search(query, { limit: 5 });
|
||||||
|
},
|
||||||
|
getItemUrl({ item }) {
|
||||||
|
return item.url;
|
||||||
|
},
|
||||||
|
onSelect: navigate,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return { autocomplete, autocompleteState };
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12.01 12a4.25 4.25 0 1 0-6.02-6 4.25 4.25 0 0 0 6.02 6Zm0 0 3.24 3.25"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoResultsIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12.01 12a4.237 4.237 0 0 0 1.24-3c0-.62-.132-1.207-.37-1.738M12.01 12A4.237 4.237 0 0 1 9 13.25c-.635 0-1.237-.14-1.777-.388M12.01 12l3.24 3.25m-3.715-9.661a4.25 4.25 0 0 0-5.975 5.908M4.5 15.5l11-11"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||||
|
<circle cx="10" cy="10" r="5.5" strokeLinejoin="round" />
|
||||||
|
<path
|
||||||
|
stroke={`url(#${id})`}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.5 10a5.5 5.5 0 1 0-5.5 5.5"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id={id}
|
||||||
|
x1="13"
|
||||||
|
x2="9.5"
|
||||||
|
y1="9"
|
||||||
|
y2="15"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<stop stopColor="currentColor" />
|
||||||
|
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HighlightQuery({ text, query }: { text: string; query: string }) {
|
||||||
|
return (
|
||||||
|
<Highlighter
|
||||||
|
highlightClassName="underline bg-transparent text-emerald-500"
|
||||||
|
searchWords={[query]}
|
||||||
|
autoEscape={true}
|
||||||
|
textToHighlight={text}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchResult({
|
||||||
|
result,
|
||||||
|
resultIndex,
|
||||||
|
autocomplete,
|
||||||
|
collection,
|
||||||
|
query,
|
||||||
|
}: {
|
||||||
|
result: Result;
|
||||||
|
resultIndex: number;
|
||||||
|
autocomplete: Autocomplete;
|
||||||
|
collection: AutocompleteCollection<Result>;
|
||||||
|
query: string;
|
||||||
|
}) {
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
|
const sectionTitle = navigation.find((section) =>
|
||||||
|
section.links.find((link) => link.href === result.url.split("#")[0])
|
||||||
|
)?.title;
|
||||||
|
const hierarchy = [sectionTitle, result.pageTitle].filter(
|
||||||
|
(x): x is string => typeof x === "string"
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={clsx(
|
||||||
|
"group block cursor-default px-4 py-3 aria-selected:bg-zinc-50 dark:aria-selected:bg-zinc-800/50",
|
||||||
|
resultIndex > 0 && "border-t border-zinc-100 dark:border-zinc-800"
|
||||||
|
)}
|
||||||
|
aria-labelledby={`${id}-hierarchy ${id}-title`}
|
||||||
|
{...autocomplete.getItemProps({
|
||||||
|
item: result,
|
||||||
|
source: collection.source,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id={`${id}-title`}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="text-sm font-medium text-zinc-900 group-aria-selected:text-emerald-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<HighlightQuery text={result.title} query={query} />
|
||||||
|
</div>
|
||||||
|
{hierarchy.length > 0 && (
|
||||||
|
<div
|
||||||
|
id={`${id}-hierarchy`}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mt-1 truncate whitespace-nowrap text-2xs text-zinc-500"
|
||||||
|
>
|
||||||
|
{hierarchy.map((item, itemIndex, items) => (
|
||||||
|
<Fragment key={itemIndex}>
|
||||||
|
<HighlightQuery text={item} query={query} />
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
itemIndex === items.length - 1
|
||||||
|
? "sr-only"
|
||||||
|
: "mx-2 text-zinc-300 dark:text-zinc-700"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchResults({
|
||||||
|
autocomplete,
|
||||||
|
query,
|
||||||
|
collection,
|
||||||
|
}: {
|
||||||
|
autocomplete: Autocomplete;
|
||||||
|
query: string;
|
||||||
|
collection: AutocompleteCollection<Result>;
|
||||||
|
}) {
|
||||||
|
if (collection.items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 text-center">
|
||||||
|
<NoResultsIcon className="mx-auto h-5 w-5 stroke-zinc-900 dark:stroke-zinc-600" />
|
||||||
|
<p className="mt-2 text-xs text-zinc-700 dark:text-zinc-400">
|
||||||
|
Nothing found for{" "}
|
||||||
|
<strong className="break-words font-semibold text-zinc-900 dark:text-white">
|
||||||
|
‘{query}’
|
||||||
|
</strong>
|
||||||
|
. Please try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul {...autocomplete.getListProps()}>
|
||||||
|
{collection.items.map((result, resultIndex) => (
|
||||||
|
<SearchResult
|
||||||
|
key={result.url}
|
||||||
|
result={result}
|
||||||
|
resultIndex={resultIndex}
|
||||||
|
autocomplete={autocomplete}
|
||||||
|
collection={collection}
|
||||||
|
query={query}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchInput = forwardRef<
|
||||||
|
React.ElementRef<"input">,
|
||||||
|
{
|
||||||
|
autocomplete: Autocomplete;
|
||||||
|
autocompleteState: AutocompleteState<Result> | EmptyObject;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
>(function SearchInput({ autocomplete, autocompleteState, onClose }, inputRef) {
|
||||||
|
const inputProps = autocomplete.getInputProps({ inputElement: null });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative flex h-12">
|
||||||
|
<SearchIcon className="pointer-events-none absolute left-3 top-0 h-full w-5 stroke-zinc-500" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className={clsx(
|
||||||
|
"flex-auto appearance-none bg-transparent pl-10 text-zinc-900 outline-none placeholder:text-zinc-500 focus:w-full focus:flex-none dark:text-white sm:text-sm [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden [&::-webkit-search-results-button]:hidden [&::-webkit-search-results-decoration]:hidden",
|
||||||
|
autocompleteState.status === "stalled" ? "pr-11" : "pr-4"
|
||||||
|
)}
|
||||||
|
{...inputProps}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (
|
||||||
|
event.key === "Escape" &&
|
||||||
|
!autocompleteState.isOpen &&
|
||||||
|
autocompleteState.query === ""
|
||||||
|
) {
|
||||||
|
// In Safari, closing the dialog with the escape key can sometimes cause the scroll position to jump to the
|
||||||
|
// bottom of the page. This is a workaround for that until we can figure out a proper fix in Headless UI.
|
||||||
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
inputProps.onKeyDown(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{autocompleteState.status === "stalled" && (
|
||||||
|
<div className="absolute inset-y-0 right-3 flex items-center">
|
||||||
|
<LoadingIcon className="h-5 w-5 animate-spin stroke-zinc-200 text-zinc-900 dark:stroke-zinc-800 dark:text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function SearchDialog({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const formRef = useRef<React.ElementRef<"form">>(null);
|
||||||
|
const panelRef = useRef<React.ElementRef<"div">>(null);
|
||||||
|
const inputRef = useRef<React.ElementRef<typeof SearchInput>>(null);
|
||||||
|
const { autocomplete, autocompleteState } = useAutocomplete({
|
||||||
|
close() {
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, [pathname, searchParams, setOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [open, setOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition.Root
|
||||||
|
show={open}
|
||||||
|
as={Fragment}
|
||||||
|
afterLeave={() => autocomplete.setQuery("")}
|
||||||
|
>
|
||||||
|
<Dialog
|
||||||
|
onClose={setOpen}
|
||||||
|
className={clsx("fixed inset-0 z-50", className)}
|
||||||
|
>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-zinc-400/25 backdrop-blur-sm dark:bg-black/40" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-y-auto px-4 py-4 sm:px-6 sm:py-20 md:py-32 lg:px-8 lg:py-[15vh]">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className="mx-auto transform-gpu overflow-hidden rounded-lg bg-zinc-50 shadow-xl ring-1 ring-zinc-900/7.5 dark:bg-zinc-900 dark:ring-zinc-800 sm:max-w-xl">
|
||||||
|
<div {...autocomplete.getRootProps({})}>
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
{...autocomplete.getFormProps({
|
||||||
|
inputElement: inputRef.current,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SearchInput
|
||||||
|
ref={inputRef}
|
||||||
|
autocomplete={autocomplete}
|
||||||
|
autocompleteState={autocompleteState}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="border-t border-zinc-200 bg-white empty:hidden dark:border-zinc-100/5 dark:bg-white/2.5"
|
||||||
|
{...autocomplete.getPanelProps({})}
|
||||||
|
>
|
||||||
|
{autocompleteState.isOpen && (
|
||||||
|
<SearchResults
|
||||||
|
autocomplete={autocomplete}
|
||||||
|
query={autocompleteState.query}
|
||||||
|
collection={autocompleteState.collections[0]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSearchProps() {
|
||||||
|
const buttonRef = useRef<React.ElementRef<"button">>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return {
|
||||||
|
buttonProps: {
|
||||||
|
ref: buttonRef,
|
||||||
|
onClick() {
|
||||||
|
setOpen(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dialogProps: {
|
||||||
|
open,
|
||||||
|
setOpen: useCallback(
|
||||||
|
(open: boolean) => {
|
||||||
|
const { width = 0, height = 0 } =
|
||||||
|
buttonRef.current?.getBoundingClientRect() ?? {};
|
||||||
|
if (!open || (width !== 0 && height !== 0)) {
|
||||||
|
setOpen(open);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setOpen]
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Search() {
|
||||||
|
const [modifierKey, setModifierKey] = useState<string>();
|
||||||
|
const { buttonProps, dialogProps } = useSearchProps();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setModifierKey(
|
||||||
|
/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) ? "⌘" : "Ctrl "
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hidden lg:block lg:max-w-md lg:flex-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hidden h-8 w-full items-center gap-2 rounded-full bg-white pl-2 pr-3 text-sm text-zinc-500 ring-1 ring-zinc-900/10 transition hover:ring-zinc-900/20 ui-not-focus-visible:outline-none dark:bg-white/5 dark:text-zinc-400 dark:ring-inset dark:ring-white/10 dark:hover:ring-white/20 lg:flex"
|
||||||
|
{...buttonProps}
|
||||||
|
>
|
||||||
|
<SearchIcon className="h-5 w-5 stroke-current" />
|
||||||
|
Find something...
|
||||||
|
<kbd className="ml-auto text-2xs text-zinc-400 dark:text-zinc-500">
|
||||||
|
<kbd className="font-sans">{modifierKey}</kbd>
|
||||||
|
<kbd className="font-sans">K</kbd>
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<SearchDialog className="hidden lg:block" {...dialogProps} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileSearch() {
|
||||||
|
const { buttonProps, dialogProps } = useSearchProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="contents lg:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md transition hover:bg-zinc-900/5 ui-not-focus-visible:outline-none dark:hover:bg-white/5 lg:hidden"
|
||||||
|
aria-label="Find something..."
|
||||||
|
{...buttonProps}
|
||||||
|
>
|
||||||
|
<SearchIcon className="h-5 w-5 stroke-zinc-900 dark:stroke-white" />
|
||||||
|
</button>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<SearchDialog className="lg:hidden" {...dialogProps} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
155
web/src/components/docs/SectionProvider.tsx
Normal file
155
web/src/components/docs/SectionProvider.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { remToPx } from "@/lib/remToPx";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { type StoreApi, createStore, useStore } from "zustand";
|
||||||
|
|
||||||
|
export interface Section {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
offsetRem?: number;
|
||||||
|
tag?: string;
|
||||||
|
headingRef?: React.RefObject<HTMLHeadingElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionState {
|
||||||
|
sections: Array<Section>;
|
||||||
|
visibleSections: Array<string>;
|
||||||
|
setVisibleSections: (visibleSections: Array<string>) => void;
|
||||||
|
registerHeading: ({
|
||||||
|
id,
|
||||||
|
ref,
|
||||||
|
offsetRem,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
ref: React.RefObject<HTMLHeadingElement>;
|
||||||
|
offsetRem: number;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSectionStore(sections: Array<Section>) {
|
||||||
|
return createStore<SectionState>()((set) => ({
|
||||||
|
sections,
|
||||||
|
visibleSections: [],
|
||||||
|
setVisibleSections: (visibleSections) =>
|
||||||
|
set((state) =>
|
||||||
|
state.visibleSections.join() === visibleSections.join()
|
||||||
|
? {}
|
||||||
|
: { visibleSections }
|
||||||
|
),
|
||||||
|
registerHeading: ({ id, ref, offsetRem }) =>
|
||||||
|
set((state) => {
|
||||||
|
return {
|
||||||
|
sections: state.sections.map((section) => {
|
||||||
|
if (section.id === id) {
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
headingRef: ref,
|
||||||
|
offsetRem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return section;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function useVisibleSections(sectionStore: StoreApi<SectionState>) {
|
||||||
|
const setVisibleSections = useStore(
|
||||||
|
sectionStore,
|
||||||
|
(s) => s.setVisibleSections
|
||||||
|
);
|
||||||
|
const sections = useStore(sectionStore, (s) => s.sections);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function checkVisibleSections() {
|
||||||
|
const { innerHeight, scrollY } = window;
|
||||||
|
const newVisibleSections = [];
|
||||||
|
|
||||||
|
for (
|
||||||
|
let sectionIndex = 0;
|
||||||
|
sectionIndex < sections.length;
|
||||||
|
sectionIndex++
|
||||||
|
) {
|
||||||
|
const { id, headingRef, offsetRem = 0 } = sections[sectionIndex];
|
||||||
|
|
||||||
|
if (!headingRef?.current) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = remToPx(offsetRem);
|
||||||
|
const top = headingRef.current.getBoundingClientRect().top + scrollY;
|
||||||
|
|
||||||
|
if (sectionIndex === 0 && top - offset > scrollY) {
|
||||||
|
newVisibleSections.push("_top");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSection = sections[sectionIndex + 1];
|
||||||
|
const bottom =
|
||||||
|
(nextSection?.headingRef?.current?.getBoundingClientRect().top ??
|
||||||
|
Infinity) +
|
||||||
|
scrollY -
|
||||||
|
remToPx(nextSection?.offsetRem ?? 0);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(top > scrollY && top < scrollY + innerHeight) ||
|
||||||
|
(bottom > scrollY && bottom < scrollY + innerHeight) ||
|
||||||
|
(top <= scrollY && bottom >= scrollY + innerHeight)
|
||||||
|
) {
|
||||||
|
newVisibleSections.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisibleSections(newVisibleSections);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raf = window.requestAnimationFrame(() => checkVisibleSections());
|
||||||
|
window.addEventListener("scroll", checkVisibleSections, { passive: true });
|
||||||
|
window.addEventListener("resize", checkVisibleSections);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(raf);
|
||||||
|
window.removeEventListener("scroll", checkVisibleSections);
|
||||||
|
window.removeEventListener("resize", checkVisibleSections);
|
||||||
|
};
|
||||||
|
}, [setVisibleSections, sections]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SectionStoreContext = createContext<StoreApi<SectionState> | null>(null);
|
||||||
|
|
||||||
|
const useIsomorphicLayoutEffect =
|
||||||
|
typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||||
|
|
||||||
|
export function SectionProvider({
|
||||||
|
sections,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
sections: Array<Section>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [sectionStore] = useState(() => createSectionStore(sections));
|
||||||
|
|
||||||
|
useVisibleSections(sectionStore);
|
||||||
|
|
||||||
|
useIsomorphicLayoutEffect(() => {
|
||||||
|
sectionStore.setState({ sections });
|
||||||
|
}, [sectionStore, sections]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionStoreContext.Provider value={sectionStore}>
|
||||||
|
{children}
|
||||||
|
</SectionStoreContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSectionStore<T>(selector: (state: SectionState) => T) {
|
||||||
|
const store = useContext(SectionStoreContext);
|
||||||
|
return useStore(store!, selector);
|
||||||
|
}
|
63
web/src/components/docs/Tag.tsx
Normal file
63
web/src/components/docs/Tag.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
small: '',
|
||||||
|
medium: 'rounded-lg px-1.5 ring-1 ring-inset',
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorStyles = {
|
||||||
|
emerald: {
|
||||||
|
small: 'text-emerald-500 dark:text-emerald-400',
|
||||||
|
medium:
|
||||||
|
'ring-emerald-300 dark:ring-emerald-400/30 bg-emerald-400/10 text-emerald-500 dark:text-emerald-400',
|
||||||
|
},
|
||||||
|
sky: {
|
||||||
|
small: 'text-sky-500',
|
||||||
|
medium:
|
||||||
|
'ring-sky-300 bg-sky-400/10 text-sky-500 dark:ring-sky-400/30 dark:bg-sky-400/10 dark:text-sky-400',
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
small: 'text-amber-500',
|
||||||
|
medium:
|
||||||
|
'ring-amber-300 bg-amber-400/10 text-amber-500 dark:ring-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400',
|
||||||
|
},
|
||||||
|
rose: {
|
||||||
|
small: 'text-red-500 dark:text-rose-500',
|
||||||
|
medium:
|
||||||
|
'ring-rose-200 bg-rose-50 text-red-500 dark:ring-rose-500/20 dark:bg-rose-400/10 dark:text-rose-400',
|
||||||
|
},
|
||||||
|
zinc: {
|
||||||
|
small: 'text-zinc-400 dark:text-zinc-500',
|
||||||
|
medium:
|
||||||
|
'ring-zinc-200 bg-zinc-50 text-zinc-500 dark:ring-zinc-500/20 dark:bg-zinc-400/10 dark:text-zinc-400',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueColorMap = {
|
||||||
|
GET: 'emerald',
|
||||||
|
POST: 'sky',
|
||||||
|
PUT: 'amber',
|
||||||
|
DELETE: 'rose',
|
||||||
|
} as Record<string, keyof typeof colorStyles>
|
||||||
|
|
||||||
|
export function Tag({
|
||||||
|
children,
|
||||||
|
variant = 'medium',
|
||||||
|
color = valueColorMap[children] ?? 'emerald',
|
||||||
|
}: {
|
||||||
|
children: keyof typeof valueColorMap & (string | {})
|
||||||
|
variant?: keyof typeof variantStyles
|
||||||
|
color?: keyof typeof colorStyles
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'font-mono text-[0.625rem] font-semibold leading-6',
|
||||||
|
variantStyles[variant],
|
||||||
|
colorStyles[color][variant],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
44
web/src/components/docs/ThemeToggle.tsx
Normal file
44
web/src/components/docs/ThemeToggle.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
|
||||||
|
function SunIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||||
|
<path d="M12.5 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Z" />
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
d="M10 5.5v-1M13.182 6.818l.707-.707M14.5 10h1M13.182 13.182l.707.707M10 15.5v-1M6.11 13.889l.708-.707M4.5 10h1M6.11 6.111l.708.707"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoonIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||||
|
<path d="M15.224 11.724a5.5 5.5 0 0 1-6.949-6.949 5.5 5.5 0 1 0 6.949 6.949Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
let { resolvedTheme, setTheme } = useTheme()
|
||||||
|
let otherTheme = resolvedTheme === 'dark' ? 'light' : 'dark'
|
||||||
|
let [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md transition hover:bg-zinc-900/5 dark:hover:bg-white/5"
|
||||||
|
aria-label={mounted ? `Switch to ${otherTheme} theme` : 'Toggle theme'}
|
||||||
|
onClick={() => setTheme(otherTheme)}
|
||||||
|
>
|
||||||
|
<SunIcon className="h-5 w-5 stroke-zinc-900 dark:hidden" />
|
||||||
|
<MoonIcon className="hidden h-5 w-5 stroke-white dark:block" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
144
web/src/components/docs/mdx.tsx
Normal file
144
web/src/components/docs/mdx.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { Feedback } from "@/components/docs/Feedback";
|
||||||
|
import { Heading } from "@/components/docs/Heading";
|
||||||
|
import { Prose } from "@/components/docs/Prose";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const a = Link;
|
||||||
|
export { Button } from "@/components/docs/Button";
|
||||||
|
export { CodeGroup, Code as code, Pre as pre } from "@/components/docs/Code";
|
||||||
|
|
||||||
|
export function wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<article className="flex h-full flex-col pb-10 pt-16">
|
||||||
|
<Prose className="flex-auto">{children}</Prose>
|
||||||
|
<footer className="mx-auto mt-16 w-full max-w-2xl lg:max-w-5xl">
|
||||||
|
<Feedback />
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const h2 = function H2(
|
||||||
|
props: Omit<React.ComponentPropsWithoutRef<typeof Heading>, "level">
|
||||||
|
) {
|
||||||
|
return <Heading level={2} {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
function InfoIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true" {...props}>
|
||||||
|
<circle cx="8" cy="8" r="8" strokeWidth="0" />
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
d="M6.75 7.75h1.5v3.5"
|
||||||
|
/>
|
||||||
|
<circle cx="8" cy="4" r=".5" fill="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Image({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<"img">) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-2xl ring-1 ring-stone-200 p-2 bg-stone-100 object-contain",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
loading="lazy"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Note({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="my-6 flex gap-2.5 rounded-2xl border border-emerald-500/20 bg-emerald-50/50 p-4 leading-6 text-emerald-900 dark:border-emerald-500/30 dark:bg-emerald-500/5 dark:text-emerald-200 dark:[--tw-prose-links-hover:theme(colors.emerald.300)] dark:[--tw-prose-links:theme(colors.white)]">
|
||||||
|
<InfoIcon className="mt-1 h-4 w-4 flex-none fill-emerald-500 stroke-white dark:fill-emerald-200/20 dark:stroke-emerald-200" />
|
||||||
|
<div className="[&>:first-child]:mt-0 [&>:last-child]:mb-0">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Row({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 items-start gap-x-16 gap-y-10 xl:max-w-none xl:grid-cols-2">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Col({
|
||||||
|
children,
|
||||||
|
sticky = false,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
sticky?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"[&>:first-child]:mt-0 [&>:last-child]:mb-0",
|
||||||
|
sticky && "xl:sticky xl:top-24"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Properties({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="my-6">
|
||||||
|
<ul
|
||||||
|
role="list"
|
||||||
|
className="m-0 max-w-[calc(theme(maxWidth.lg)-theme(spacing.8))] list-none divide-y divide-zinc-900/5 p-0 dark:divide-white/5"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Property({
|
||||||
|
name,
|
||||||
|
children,
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
type?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li className="m-0 px-0 py-4 first:pt-0 last:pb-0">
|
||||||
|
<dl className="m-0 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
|
<dt className="sr-only">Name</dt>
|
||||||
|
<dd>
|
||||||
|
<code>{name}</code>
|
||||||
|
</dd>
|
||||||
|
{type && (
|
||||||
|
<>
|
||||||
|
<dt className="sr-only">Type</dt>
|
||||||
|
<dd className="font-mono text-xs text-zinc-400 dark:text-zinc-500">
|
||||||
|
{type}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<dt className="sr-only">Description</dt>
|
||||||
|
<dd className="w-full flex-none [&>:first-child]:mt-0 [&>:last-child]:mb-0">
|
||||||
|
{children}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
@ -1,11 +1,10 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import { cn } from "@/lib/utils";
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
const Tabs = TabsPrimitive.Root
|
|
||||||
|
|
||||||
const TabsList = React.forwardRef<
|
const TabsList = React.forwardRef<
|
||||||
React.ElementRef<typeof TabsPrimitive.List>,
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
@ -19,8 +18,8 @@ const TabsList = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TabsList.displayName = TabsPrimitive.List.displayName
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
const TabsTrigger = React.forwardRef<
|
const TabsTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
@ -29,13 +28,13 @@ const TabsTrigger = React.forwardRef<
|
|||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 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]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1 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]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
const TabsContent = React.forwardRef<
|
const TabsContent = React.forwardRef<
|
||||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
@ -49,7 +48,7 @@ const TabsContent = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
|
8
web/src/lib/remToPx.ts
Normal file
8
web/src/lib/remToPx.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export function remToPx(remValue: number) {
|
||||||
|
let rootFontSize =
|
||||||
|
typeof window === 'undefined'
|
||||||
|
? 16
|
||||||
|
: parseFloat(window.getComputedStyle(document.documentElement).fontSize)
|
||||||
|
|
||||||
|
return remValue * rootFontSize
|
||||||
|
}
|
3
web/src/mdx/recma.mjs
Normal file
3
web/src/mdx/recma.mjs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { mdxAnnotations } from "mdx-annotations";
|
||||||
|
|
||||||
|
export const recmaPlugins = [mdxAnnotations.recma];
|
124
web/src/mdx/rehype.mjs
Normal file
124
web/src/mdx/rehype.mjs
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { slugifyWithCounter } from "@sindresorhus/slugify";
|
||||||
|
import * as acorn from "acorn";
|
||||||
|
import { toString } from "mdast-util-to-string";
|
||||||
|
import { mdxAnnotations } from "mdx-annotations";
|
||||||
|
import shiki from "shiki";
|
||||||
|
import { visit } from "unist-util-visit";
|
||||||
|
|
||||||
|
function rehypeParseCodeBlocks() {
|
||||||
|
return (tree) => {
|
||||||
|
visit(tree, "element", (node, _nodeIndex, parentNode) => {
|
||||||
|
if (node.tagName === "code" && node.properties.className) {
|
||||||
|
parentNode.properties.language = node.properties.className[0]?.replace(
|
||||||
|
/^language-/,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let highlighter;
|
||||||
|
|
||||||
|
function rehypeShiki() {
|
||||||
|
return async (tree) => {
|
||||||
|
highlighter =
|
||||||
|
highlighter ?? (await shiki.getHighlighter({ theme: "css-variables" }));
|
||||||
|
|
||||||
|
visit(tree, "element", (node) => {
|
||||||
|
if (node.tagName === "pre" && node.children[0]?.tagName === "code") {
|
||||||
|
let codeNode = node.children[0];
|
||||||
|
let textNode = codeNode.children[0];
|
||||||
|
|
||||||
|
node.properties.code = textNode.value;
|
||||||
|
|
||||||
|
if (node.properties.language) {
|
||||||
|
let tokens = highlighter.codeToThemedTokens(
|
||||||
|
textNode.value,
|
||||||
|
node.properties.language
|
||||||
|
);
|
||||||
|
|
||||||
|
textNode.value = shiki.renderToHtml(tokens, {
|
||||||
|
elements: {
|
||||||
|
pre: ({ children }) => children,
|
||||||
|
code: ({ children }) => children,
|
||||||
|
line: ({ children }) => `<span>${children}</span>`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rehypeSlugify() {
|
||||||
|
return (tree) => {
|
||||||
|
let slugify = slugifyWithCounter();
|
||||||
|
visit(tree, "element", (node) => {
|
||||||
|
if (node.tagName === "h2" && !node.properties.id) {
|
||||||
|
node.properties.id = slugify(toString(node));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rehypeAddMDXExports(getExports) {
|
||||||
|
return (tree) => {
|
||||||
|
let exports = Object.entries(getExports(tree));
|
||||||
|
|
||||||
|
for (let [name, value] of exports) {
|
||||||
|
for (let node of tree.children) {
|
||||||
|
if (
|
||||||
|
node.type === "mdxjsEsm" &&
|
||||||
|
new RegExp(`export\\s+const\\s+${name}\\s*=`).test(node.value)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let exportStr = `export const ${name} = ${value}`;
|
||||||
|
|
||||||
|
tree.children.push({
|
||||||
|
type: "mdxjsEsm",
|
||||||
|
value: exportStr,
|
||||||
|
data: {
|
||||||
|
estree: acorn.parse(exportStr, {
|
||||||
|
sourceType: "module",
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSections(node) {
|
||||||
|
let sections = [];
|
||||||
|
|
||||||
|
for (let child of node.children ?? []) {
|
||||||
|
if (child.type === "element" && child.tagName === "h2") {
|
||||||
|
sections.push(`{
|
||||||
|
title: ${JSON.stringify(toString(child))},
|
||||||
|
id: ${JSON.stringify(child.properties.id)},
|
||||||
|
...${child.properties.annotation}
|
||||||
|
}`);
|
||||||
|
} else if (child.children) {
|
||||||
|
sections.push(...getSections(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rehypePlugins = [
|
||||||
|
mdxAnnotations.rehype,
|
||||||
|
rehypeParseCodeBlocks,
|
||||||
|
rehypeShiki,
|
||||||
|
rehypeSlugify,
|
||||||
|
[
|
||||||
|
rehypeAddMDXExports,
|
||||||
|
(tree) => ({
|
||||||
|
sections: `[${getSections(tree).join()}]`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
];
|
4
web/src/mdx/remark.mjs
Normal file
4
web/src/mdx/remark.mjs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { mdxAnnotations } from 'mdx-annotations'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
|
||||||
|
export const remarkPlugins = [mdxAnnotations.remark, remarkGfm]
|
137
web/src/mdx/search.mjs
Normal file
137
web/src/mdx/search.mjs
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { slugifyWithCounter } from "@sindresorhus/slugify";
|
||||||
|
import glob from "fast-glob";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import { toString } from "mdast-util-to-string";
|
||||||
|
import * as path from "path";
|
||||||
|
import { remark } from "remark";
|
||||||
|
import remarkMdx from "remark-mdx";
|
||||||
|
import { createLoader } from "simple-functional-loader";
|
||||||
|
import { filter } from "unist-util-filter";
|
||||||
|
import { SKIP, visit } from "unist-util-visit";
|
||||||
|
import * as url from "url";
|
||||||
|
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
const processor = remark().use(remarkMdx).use(extractSections);
|
||||||
|
const slugify = slugifyWithCounter();
|
||||||
|
|
||||||
|
function isObjectExpression(node) {
|
||||||
|
return (
|
||||||
|
node.type === "mdxTextExpression" &&
|
||||||
|
node.data?.estree?.body?.[0]?.expression?.type === "ObjectExpression"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function excludeObjectExpressions(tree) {
|
||||||
|
return filter(tree, (node) => !isObjectExpression(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSections() {
|
||||||
|
return (tree, { sections }) => {
|
||||||
|
slugify.reset();
|
||||||
|
|
||||||
|
visit(tree, (node) => {
|
||||||
|
if (node.type === "heading" || node.type === "paragraph") {
|
||||||
|
let content = toString(excludeObjectExpressions(node));
|
||||||
|
if (node.type === "heading" && node.depth <= 2) {
|
||||||
|
let hash = node.depth === 1 ? null : slugify(content);
|
||||||
|
sections.push([content, hash, []]);
|
||||||
|
} else {
|
||||||
|
sections.at(-1)?.[2].push(content);
|
||||||
|
}
|
||||||
|
return SKIP;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (nextConfig = {}) {
|
||||||
|
let cache = new Map();
|
||||||
|
|
||||||
|
return Object.assign({}, nextConfig, {
|
||||||
|
webpack(config, options) {
|
||||||
|
config.module.rules.push({
|
||||||
|
test: __filename,
|
||||||
|
use: [
|
||||||
|
createLoader(function () {
|
||||||
|
let appDir = path.resolve("./src/app/(docs)/docs");
|
||||||
|
this.addContextDependency(appDir);
|
||||||
|
|
||||||
|
let files = glob.sync("**/*.mdx", { cwd: appDir });
|
||||||
|
let data = files.map((file) => {
|
||||||
|
let url = `/${file.replace(/(^|\/)page\.mdx$/, "")}`;
|
||||||
|
let mdx = fs.readFileSync(path.join(appDir, file), "utf8");
|
||||||
|
|
||||||
|
let sections = [];
|
||||||
|
|
||||||
|
if (cache.get(file)?.[0] === mdx) {
|
||||||
|
sections = cache.get(file)[1];
|
||||||
|
} else {
|
||||||
|
let vfile = { value: mdx, sections };
|
||||||
|
processor.runSync(processor.parse(vfile), vfile);
|
||||||
|
cache.set(file, [mdx, sections]);
|
||||||
|
}
|
||||||
|
|
||||||
|
url = `/docs/${url}`;
|
||||||
|
|
||||||
|
return { url, sections };
|
||||||
|
});
|
||||||
|
|
||||||
|
// When this file is imported within the application
|
||||||
|
// the following module is loaded:
|
||||||
|
return `
|
||||||
|
import FlexSearch from 'flexsearch'
|
||||||
|
|
||||||
|
let sectionIndex = new FlexSearch.Document({
|
||||||
|
tokenize: 'full',
|
||||||
|
document: {
|
||||||
|
id: 'url',
|
||||||
|
index: 'content',
|
||||||
|
store: ['title', 'pageTitle'],
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
resolution: 9,
|
||||||
|
depth: 2,
|
||||||
|
bidirectional: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let data = ${JSON.stringify(data)}
|
||||||
|
|
||||||
|
for (let { url, sections } of data) {
|
||||||
|
for (let [title, hash, content] of sections) {
|
||||||
|
sectionIndex.add({
|
||||||
|
url: url + (hash ? ('#' + hash) : ''),
|
||||||
|
title,
|
||||||
|
content: [title, ...content].join('\\n'),
|
||||||
|
pageTitle: hash ? sections[0][0] : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function search(query, options = {}) {
|
||||||
|
let result = sectionIndex.search(query, {
|
||||||
|
...options,
|
||||||
|
enrich: true,
|
||||||
|
})
|
||||||
|
if (result.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return result[0].result.map((item) => ({
|
||||||
|
url: item.id,
|
||||||
|
title: item.doc.title,
|
||||||
|
pageTitle: item.doc.pageTitle,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof nextConfig.webpack === "function") {
|
||||||
|
return nextConfig.webpack(config, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
@ -5,7 +5,7 @@ import { authMiddleware, redirectToSignIn } from "@clerk/nextjs";
|
|||||||
// See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware
|
// See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware
|
||||||
export default authMiddleware({
|
export default authMiddleware({
|
||||||
// debug: true,
|
// debug: true,
|
||||||
publicRoutes: ['/',"/api/(.*)"],
|
publicRoutes: ["/", "/api/(.*)", "/docs(.*)"],
|
||||||
// publicRoutes: ["/", "/(.*)"],
|
// publicRoutes: ["/", "/(.*)"],
|
||||||
async afterAuth(auth, req, evt) {
|
async afterAuth(auth, req, evt) {
|
||||||
// redirect them to organization selection page
|
// redirect them to organization selection page
|
||||||
|
@ -1,14 +1,29 @@
|
|||||||
|
import typographyStyles from "./typography";
|
||||||
|
import headlessuiPlugin from "@headlessui/tailwindcss";
|
||||||
|
import typographyPlugin from "@tailwindcss/typography";
|
||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
content: [
|
content: ["./src/**/*.{js,mjs,jsx,ts,tsx,mdx}"],
|
||||||
"./pages/**/*.{ts,tsx}",
|
|
||||||
"./components/**/*.{ts,tsx}",
|
|
||||||
"./app/**/*.{ts,tsx}",
|
|
||||||
"./src/**/*.{ts,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
theme: {
|
||||||
|
fontSize: {
|
||||||
|
"2xs": ["0.75rem", { lineHeight: "1.25rem" }],
|
||||||
|
xs: ["0.8125rem", { lineHeight: "1.5rem" }],
|
||||||
|
sm: ["0.875rem", { lineHeight: "1.5rem" }],
|
||||||
|
base: ["1rem", { lineHeight: "1.75rem" }],
|
||||||
|
lg: ["1.125rem", { lineHeight: "1.75rem" }],
|
||||||
|
xl: ["1.25rem", { lineHeight: "1.75rem" }],
|
||||||
|
"2xl": ["1.5rem", { lineHeight: "2rem" }],
|
||||||
|
"3xl": ["1.875rem", { lineHeight: "2.25rem" }],
|
||||||
|
"4xl": ["2.25rem", { lineHeight: "2.5rem" }],
|
||||||
|
"5xl": ["3rem", { lineHeight: "1" }],
|
||||||
|
"6xl": ["3.75rem", { lineHeight: "1" }],
|
||||||
|
"7xl": ["4.5rem", { lineHeight: "1" }],
|
||||||
|
"8xl": ["6rem", { lineHeight: "1" }],
|
||||||
|
"9xl": ["8rem", { lineHeight: "1" }],
|
||||||
|
},
|
||||||
|
typography: typographyStyles,
|
||||||
container: {
|
container: {
|
||||||
center: true,
|
center: true,
|
||||||
padding: "2rem",
|
padding: "2rem",
|
||||||
@ -66,22 +81,37 @@ const config: Config = {
|
|||||||
from: { height: "var(--radix-accordion-content-height)" },
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
to: { height: "0" },
|
to: { height: "0" },
|
||||||
},
|
},
|
||||||
'background-shine': {
|
"background-shine": {
|
||||||
from: {
|
from: {
|
||||||
backgroundPosition: '0 0',
|
backgroundPosition: "0 0",
|
||||||
},
|
},
|
||||||
to: {
|
to: {
|
||||||
backgroundPosition: '-200% 0',
|
backgroundPosition: "-200% 0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
'background-shine': 'background-shine 2s linear infinite',
|
"background-shine": "background-shine 2s linear infinite",
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
glow: "0 0 4px rgb(0 0 0 / 0.1)",
|
||||||
|
},
|
||||||
|
maxWidth: {
|
||||||
|
lg: "33rem",
|
||||||
|
"2xl": "40rem",
|
||||||
|
"3xl": "50rem",
|
||||||
|
"5xl": "66rem",
|
||||||
|
},
|
||||||
|
opacity: {
|
||||||
|
1: "0.01",
|
||||||
|
2.5: "0.025",
|
||||||
|
7.5: "0.075",
|
||||||
|
15: "0.15",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [require("tailwindcss-animate"), typographyPlugin, headlessuiPlugin],
|
||||||
};
|
};
|
||||||
export default config;
|
export default config;
|
||||||
|
11
web/types.d.ts
vendored
Normal file
11
web/types.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { type SearchOptions } from 'flexsearch'
|
||||||
|
|
||||||
|
declare module '@/mdx/search.mjs' {
|
||||||
|
export type Result = {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
pageTitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function search(query: string, options?: SearchOptions): Array<Result>
|
||||||
|
}
|
353
web/typography.ts
Normal file
353
web/typography.ts
Normal file
@ -0,0 +1,353 @@
|
|||||||
|
import { type PluginUtils } from "tailwindcss/types/config";
|
||||||
|
|
||||||
|
export default function typographyStyles({ theme }: PluginUtils) {
|
||||||
|
return {
|
||||||
|
DEFAULT: {
|
||||||
|
css: {
|
||||||
|
"--tw-prose-body": theme("colors.zinc.700"),
|
||||||
|
"--tw-prose-headings": theme("colors.zinc.900"),
|
||||||
|
"--tw-prose-links": theme("colors.emerald.500"),
|
||||||
|
"--tw-prose-links-hover": theme("colors.emerald.600"),
|
||||||
|
"--tw-prose-links-underline": theme("colors.emerald.500 / 0.3"),
|
||||||
|
"--tw-prose-bold": theme("colors.zinc.900"),
|
||||||
|
"--tw-prose-counters": theme("colors.zinc.500"),
|
||||||
|
"--tw-prose-bullets": theme("colors.zinc.300"),
|
||||||
|
"--tw-prose-hr": theme("colors.zinc.900 / 0.05"),
|
||||||
|
"--tw-prose-quotes": theme("colors.zinc.900"),
|
||||||
|
"--tw-prose-quote-borders": theme("colors.zinc.200"),
|
||||||
|
"--tw-prose-captions": theme("colors.zinc.500"),
|
||||||
|
"--tw-prose-code": theme("colors.zinc.900"),
|
||||||
|
"--tw-prose-code-bg": theme("colors.zinc.100"),
|
||||||
|
"--tw-prose-code-ring": theme("colors.zinc.300"),
|
||||||
|
"--tw-prose-th-borders": theme("colors.zinc.300"),
|
||||||
|
"--tw-prose-td-borders": theme("colors.zinc.200"),
|
||||||
|
|
||||||
|
"--tw-prose-invert-body": theme("colors.zinc.400"),
|
||||||
|
"--tw-prose-invert-headings": theme("colors.white"),
|
||||||
|
"--tw-prose-invert-links": theme("colors.emerald.400"),
|
||||||
|
"--tw-prose-invert-links-hover": theme("colors.emerald.500"),
|
||||||
|
"--tw-prose-invert-links-underline": theme("colors.emerald.500 / 0.3"),
|
||||||
|
"--tw-prose-invert-bold": theme("colors.white"),
|
||||||
|
"--tw-prose-invert-counters": theme("colors.zinc.400"),
|
||||||
|
"--tw-prose-invert-bullets": theme("colors.zinc.600"),
|
||||||
|
"--tw-prose-invert-hr": theme("colors.white / 0.05"),
|
||||||
|
"--tw-prose-invert-quotes": theme("colors.zinc.100"),
|
||||||
|
"--tw-prose-invert-quote-borders": theme("colors.zinc.700"),
|
||||||
|
"--tw-prose-invert-captions": theme("colors.zinc.400"),
|
||||||
|
"--tw-prose-invert-code": theme("colors.white"),
|
||||||
|
"--tw-prose-invert-code-bg": theme("colors.zinc.700 / 0.15"),
|
||||||
|
"--tw-prose-invert-code-ring": theme("colors.white / 0.1"),
|
||||||
|
"--tw-prose-invert-th-borders": theme("colors.zinc.600"),
|
||||||
|
"--tw-prose-invert-td-borders": theme("colors.zinc.700"),
|
||||||
|
|
||||||
|
// Base
|
||||||
|
color: "var(--tw-prose-body)",
|
||||||
|
fontSize: theme("fontSize.sm")[0],
|
||||||
|
lineHeight: theme("lineHeight.7"),
|
||||||
|
|
||||||
|
// Text
|
||||||
|
p: {
|
||||||
|
marginTop: theme("spacing.6"),
|
||||||
|
marginBottom: theme("spacing.6"),
|
||||||
|
},
|
||||||
|
'[class~="lead"]': {
|
||||||
|
fontSize: theme("fontSize.base")[0],
|
||||||
|
...theme("fontSize.base")[1],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Lists
|
||||||
|
ol: {
|
||||||
|
listStyleType: "decimal",
|
||||||
|
marginTop: theme("spacing.5"),
|
||||||
|
marginBottom: theme("spacing.5"),
|
||||||
|
paddingLeft: "1.625rem",
|
||||||
|
},
|
||||||
|
'ol[type="A"]': {
|
||||||
|
listStyleType: "upper-alpha",
|
||||||
|
},
|
||||||
|
'ol[type="a"]': {
|
||||||
|
listStyleType: "lower-alpha",
|
||||||
|
},
|
||||||
|
'ol[type="A" s]': {
|
||||||
|
listStyleType: "upper-alpha",
|
||||||
|
},
|
||||||
|
'ol[type="a" s]': {
|
||||||
|
listStyleType: "lower-alpha",
|
||||||
|
},
|
||||||
|
'ol[type="I"]': {
|
||||||
|
listStyleType: "upper-roman",
|
||||||
|
},
|
||||||
|
'ol[type="i"]': {
|
||||||
|
listStyleType: "lower-roman",
|
||||||
|
},
|
||||||
|
'ol[type="I" s]': {
|
||||||
|
listStyleType: "upper-roman",
|
||||||
|
},
|
||||||
|
'ol[type="i" s]': {
|
||||||
|
listStyleType: "lower-roman",
|
||||||
|
},
|
||||||
|
'ol[type="1"]': {
|
||||||
|
listStyleType: "decimal",
|
||||||
|
},
|
||||||
|
ul: {
|
||||||
|
listStyleType: "disc",
|
||||||
|
marginTop: theme("spacing.5"),
|
||||||
|
marginBottom: theme("spacing.5"),
|
||||||
|
paddingLeft: "1.625rem",
|
||||||
|
},
|
||||||
|
li: {
|
||||||
|
marginTop: theme("spacing.2"),
|
||||||
|
marginBottom: theme("spacing.2"),
|
||||||
|
},
|
||||||
|
":is(ol, ul) > li": {
|
||||||
|
paddingLeft: theme("spacing[1.5]"),
|
||||||
|
},
|
||||||
|
"ol > li::marker": {
|
||||||
|
fontWeight: "400",
|
||||||
|
color: "var(--tw-prose-counters)",
|
||||||
|
},
|
||||||
|
"ul > li::marker": {
|
||||||
|
color: "var(--tw-prose-bullets)",
|
||||||
|
},
|
||||||
|
"> ul > li p": {
|
||||||
|
marginTop: theme("spacing.3"),
|
||||||
|
marginBottom: theme("spacing.3"),
|
||||||
|
},
|
||||||
|
"> ul > li > *:first-child": {
|
||||||
|
marginTop: theme("spacing.5"),
|
||||||
|
},
|
||||||
|
"> ul > li > *:last-child": {
|
||||||
|
marginBottom: theme("spacing.5"),
|
||||||
|
},
|
||||||
|
"> ol > li > *:first-child": {
|
||||||
|
marginTop: theme("spacing.5"),
|
||||||
|
},
|
||||||
|
"> ol > li > *:last-child": {
|
||||||
|
marginBottom: theme("spacing.5"),
|
||||||
|
},
|
||||||
|
"ul ul, ul ol, ol ul, ol ol": {
|
||||||
|
marginTop: theme("spacing.3"),
|
||||||
|
marginBottom: theme("spacing.3"),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Horizontal rules
|
||||||
|
hr: {
|
||||||
|
borderColor: "var(--tw-prose-hr)",
|
||||||
|
borderTopWidth: 1,
|
||||||
|
marginTop: theme("spacing.16"),
|
||||||
|
marginBottom: theme("spacing.16"),
|
||||||
|
maxWidth: "none",
|
||||||
|
marginLeft: `calc(-1 * ${theme("spacing.4")})`,
|
||||||
|
marginRight: `calc(-1 * ${theme("spacing.4")})`,
|
||||||
|
"@screen sm": {
|
||||||
|
marginLeft: `calc(-1 * ${theme("spacing.6")})`,
|
||||||
|
marginRight: `calc(-1 * ${theme("spacing.6")})`,
|
||||||
|
},
|
||||||
|
"@screen lg": {
|
||||||
|
marginLeft: `calc(-1 * ${theme("spacing.8")})`,
|
||||||
|
marginRight: `calc(-1 * ${theme("spacing.8")})`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Quotes
|
||||||
|
blockquote: {
|
||||||
|
fontWeight: "500",
|
||||||
|
fontStyle: "italic",
|
||||||
|
color: "var(--tw-prose-quotes)",
|
||||||
|
borderLeftWidth: "0.25rem",
|
||||||
|
borderLeftColor: "var(--tw-prose-quote-borders)",
|
||||||
|
quotes: '"\\201C""\\201D""\\2018""\\2019"',
|
||||||
|
marginTop: theme("spacing.8"),
|
||||||
|
marginBottom: theme("spacing.8"),
|
||||||
|
paddingLeft: theme("spacing.5"),
|
||||||
|
},
|
||||||
|
"blockquote p:first-of-type::before": {
|
||||||
|
content: "open-quote",
|
||||||
|
},
|
||||||
|
"blockquote p:last-of-type::after": {
|
||||||
|
content: "close-quote",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Headings
|
||||||
|
h1: {
|
||||||
|
color: "var(--tw-prose-headings)",
|
||||||
|
fontWeight: "700",
|
||||||
|
fontSize: theme("fontSize.2xl")[0],
|
||||||
|
...theme("fontSize.2xl")[1],
|
||||||
|
marginBottom: theme("spacing.2"),
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
color: "var(--tw-prose-headings)",
|
||||||
|
fontWeight: "600",
|
||||||
|
fontSize: theme("fontSize.lg")[0],
|
||||||
|
...theme("fontSize.lg")[1],
|
||||||
|
marginTop: theme("spacing.16"),
|
||||||
|
marginBottom: theme("spacing.2"),
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
color: "var(--tw-prose-headings)",
|
||||||
|
fontSize: theme("fontSize.base")[0],
|
||||||
|
...theme("fontSize.base")[1],
|
||||||
|
fontWeight: "600",
|
||||||
|
marginTop: theme("spacing.10"),
|
||||||
|
marginBottom: theme("spacing.2"),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Media
|
||||||
|
// img: {
|
||||||
|
// marginTop: theme("spacing.2"),
|
||||||
|
// marginBottom: theme("spacing.2"),
|
||||||
|
// },
|
||||||
|
"video, figure": {
|
||||||
|
marginTop: theme("spacing.8"),
|
||||||
|
marginBottom: theme("spacing.8"),
|
||||||
|
},
|
||||||
|
"figure > *": {
|
||||||
|
marginTop: "0",
|
||||||
|
marginBottom: "0",
|
||||||
|
},
|
||||||
|
figcaption: {
|
||||||
|
color: "var(--tw-prose-captions)",
|
||||||
|
fontSize: theme("fontSize.xs")[0],
|
||||||
|
...theme("fontSize.xs")[1],
|
||||||
|
marginTop: theme("spacing.2"),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tables
|
||||||
|
table: {
|
||||||
|
width: "100%",
|
||||||
|
tableLayout: "auto",
|
||||||
|
textAlign: "left",
|
||||||
|
marginTop: theme("spacing.8"),
|
||||||
|
marginBottom: theme("spacing.8"),
|
||||||
|
lineHeight: theme("lineHeight.6"),
|
||||||
|
},
|
||||||
|
thead: {
|
||||||
|
borderBottomWidth: "1px",
|
||||||
|
borderBottomColor: "var(--tw-prose-th-borders)",
|
||||||
|
},
|
||||||
|
"thead th": {
|
||||||
|
color: "var(--tw-prose-headings)",
|
||||||
|
fontWeight: "600",
|
||||||
|
verticalAlign: "bottom",
|
||||||
|
paddingRight: theme("spacing.2"),
|
||||||
|
paddingBottom: theme("spacing.2"),
|
||||||
|
paddingLeft: theme("spacing.2"),
|
||||||
|
},
|
||||||
|
"thead th:first-child": {
|
||||||
|
paddingLeft: "0",
|
||||||
|
},
|
||||||
|
"thead th:last-child": {
|
||||||
|
paddingRight: "0",
|
||||||
|
},
|
||||||
|
"tbody tr": {
|
||||||
|
borderBottomWidth: "1px",
|
||||||
|
borderBottomColor: "var(--tw-prose-td-borders)",
|
||||||
|
},
|
||||||
|
"tbody tr:last-child": {
|
||||||
|
borderBottomWidth: "0",
|
||||||
|
},
|
||||||
|
"tbody td": {
|
||||||
|
verticalAlign: "baseline",
|
||||||
|
},
|
||||||
|
tfoot: {
|
||||||
|
borderTopWidth: "1px",
|
||||||
|
borderTopColor: "var(--tw-prose-th-borders)",
|
||||||
|
},
|
||||||
|
"tfoot td": {
|
||||||
|
verticalAlign: "top",
|
||||||
|
},
|
||||||
|
":is(tbody, tfoot) td": {
|
||||||
|
paddingTop: theme("spacing.2"),
|
||||||
|
paddingRight: theme("spacing.2"),
|
||||||
|
paddingBottom: theme("spacing.2"),
|
||||||
|
paddingLeft: theme("spacing.2"),
|
||||||
|
},
|
||||||
|
":is(tbody, tfoot) td:first-child": {
|
||||||
|
paddingLeft: "0",
|
||||||
|
},
|
||||||
|
":is(tbody, tfoot) td:last-child": {
|
||||||
|
paddingRight: "0",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Inline elements
|
||||||
|
a: {
|
||||||
|
color: "var(--tw-prose-links)",
|
||||||
|
textDecoration: "underline transparent",
|
||||||
|
fontWeight: "500",
|
||||||
|
transitionProperty: "color, text-decoration-color",
|
||||||
|
transitionDuration: theme("transitionDuration.DEFAULT"),
|
||||||
|
transitionTimingFunction: theme("transitionTimingFunction.DEFAULT"),
|
||||||
|
"&:hover": {
|
||||||
|
color: "var(--tw-prose-links-hover)",
|
||||||
|
textDecorationColor: "var(--tw-prose-links-underline)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
":is(h1, h2, h3) a": {
|
||||||
|
fontWeight: "inherit",
|
||||||
|
},
|
||||||
|
strong: {
|
||||||
|
color: "var(--tw-prose-bold)",
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
":is(a, blockquote, thead th) strong": {
|
||||||
|
color: "inherit",
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
color: "var(--tw-prose-code)",
|
||||||
|
borderRadius: theme("borderRadius.lg"),
|
||||||
|
paddingTop: theme("padding.1"),
|
||||||
|
paddingRight: theme("padding[1.5]"),
|
||||||
|
paddingBottom: theme("padding.1"),
|
||||||
|
paddingLeft: theme("padding[1.5]"),
|
||||||
|
boxShadow: "inset 0 0 0 1px var(--tw-prose-code-ring)",
|
||||||
|
backgroundColor: "var(--tw-prose-code-bg)",
|
||||||
|
fontSize: theme("fontSize.2xs"),
|
||||||
|
},
|
||||||
|
":is(a, h1, h2, h3, blockquote, thead th) code": {
|
||||||
|
color: "inherit",
|
||||||
|
},
|
||||||
|
"h2 code": {
|
||||||
|
fontSize: theme("fontSize.base")[0],
|
||||||
|
fontWeight: "inherit",
|
||||||
|
},
|
||||||
|
"h3 code": {
|
||||||
|
fontSize: theme("fontSize.sm")[0],
|
||||||
|
fontWeight: "inherit",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Overrides
|
||||||
|
":is(h1, h2, h3) + *": {
|
||||||
|
marginTop: "0",
|
||||||
|
},
|
||||||
|
"> :first-child": {
|
||||||
|
marginTop: "0 !important",
|
||||||
|
},
|
||||||
|
"> :last-child": {
|
||||||
|
marginBottom: "0 !important",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invert: {
|
||||||
|
css: {
|
||||||
|
"--tw-prose-body": "var(--tw-prose-invert-body)",
|
||||||
|
"--tw-prose-headings": "var(--tw-prose-invert-headings)",
|
||||||
|
"--tw-prose-links": "var(--tw-prose-invert-links)",
|
||||||
|
"--tw-prose-links-hover": "var(--tw-prose-invert-links-hover)",
|
||||||
|
"--tw-prose-links-underline": "var(--tw-prose-invert-links-underline)",
|
||||||
|
"--tw-prose-bold": "var(--tw-prose-invert-bold)",
|
||||||
|
"--tw-prose-counters": "var(--tw-prose-invert-counters)",
|
||||||
|
"--tw-prose-bullets": "var(--tw-prose-invert-bullets)",
|
||||||
|
"--tw-prose-hr": "var(--tw-prose-invert-hr)",
|
||||||
|
"--tw-prose-quotes": "var(--tw-prose-invert-quotes)",
|
||||||
|
"--tw-prose-quote-borders": "var(--tw-prose-invert-quote-borders)",
|
||||||
|
"--tw-prose-captions": "var(--tw-prose-invert-captions)",
|
||||||
|
"--tw-prose-code": "var(--tw-prose-invert-code)",
|
||||||
|
"--tw-prose-code-bg": "var(--tw-prose-invert-code-bg)",
|
||||||
|
"--tw-prose-code-ring": "var(--tw-prose-invert-code-ring)",
|
||||||
|
"--tw-prose-th-borders": "var(--tw-prose-invert-th-borders)",
|
||||||
|
"--tw-prose-td-borders": "var(--tw-prose-invert-td-borders)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user