initial commit after project creation

This commit is contained in:
Gerhard Scheikl
2026-04-01 09:38:50 +02:00
commit b02af637d4
292 changed files with 61408 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
import { client } from "@/tina/__generated__/client";
import { type NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const relativePath = searchParams.get("relativePath");
if (!relativePath) {
return NextResponse.json(
{ error: "relativePath parameter is required" },
{ status: 400 }
);
}
try {
const result = await client.queries.apiSchema({
relativePath: relativePath,
});
// Check if the result has the expected data
if (!result.data.apiSchema?.apiSchema) {
return NextResponse.json(
{ error: "API schema data not found" },
{ status: 404 }
);
}
// Parse the schema JSON
const schemaJson = JSON.parse(result.data.apiSchema.apiSchema);
return NextResponse.json({ schema: schemaJson });
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch API schema" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,20 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { type NextRequest, NextResponse } from "next/server";
const isDev = process.env.NODE_ENV === "development";
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || "";
export async function POST(req: NextRequest) {
const { content, filename } = await req.json();
// Use /tmp instead of /public for vercel
const dir = path.join(isDev ? "public" : "/tmp", "exports");
const filePath = path.join(dir, filename);
// Ensure all parent directories exist
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, content, "utf8");
return NextResponse.json({ url: `${basePath}/api/exports/${filename}` });
}

View File

@@ -0,0 +1,31 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { type NextRequest, NextResponse } from "next/server";
const isDev = process.env.NODE_ENV === "development";
export async function GET(request: NextRequest): Promise<NextResponse> {
try {
// Extract path segments from the URL pathname
const url = new URL(request.url);
const segments = url.pathname.split("/").filter(Boolean);
const exportIndex = segments.indexOf("exports");
const pathSegments = segments.slice(exportIndex + 1);
const filePath = path.join(
isDev ? "public" : "/tmp",
"exports",
...pathSegments
);
const content = await readFile(filePath, "utf-8");
return new NextResponse(content, {
headers: {
"Content-Type": "text/markdown",
},
});
} catch (error) {
return new NextResponse("File not found", { status: 404 });
}
}

View File

@@ -0,0 +1,60 @@
import fs from "node:fs";
import path from "node:path";
import { type NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const filename = searchParams.get("filename");
if (!filename) {
return NextResponse.json(
{ error: "Filename parameter is required" },
{ status: 400 }
);
}
try {
const schemasDir = path.join(process.cwd(), "content", "apiSchema");
const filePath = path.join(schemasDir, filename);
// Security check: ensure the file is within the schemas directory
const resolvedPath = path.resolve(filePath);
const resolvedSchemasDir = path.resolve(schemasDir);
if (!resolvedPath.startsWith(resolvedSchemasDir)) {
return NextResponse.json({ error: "Invalid file path" }, { status: 400 });
}
if (!fs.existsSync(filePath)) {
return NextResponse.json(
{ error: "Schema file not found" },
{ status: 404 }
);
}
const fileContent = fs.readFileSync(filePath, "utf-8");
const parsedFile = JSON.parse(fileContent);
// The actual API schema is stored as a string in the apiSchema property
// We need to parse it again to get the actual OpenAPI spec
let apiSchema: any;
if (parsedFile.apiSchema && typeof parsedFile.apiSchema === "string") {
apiSchema = JSON.parse(parsedFile.apiSchema);
} else {
apiSchema = parsedFile.apiSchema || parsedFile;
}
return NextResponse.json({ apiSchema });
} catch (error) {
if (error instanceof SyntaxError) {
return NextResponse.json(
{ error: "Invalid JSON in schema file" },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Failed to read API schema" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,35 @@
import fs from "node:fs";
import path from "node:path";
import { NextResponse } from "next/server";
export async function GET() {
try {
const schemasDir = path.join(process.cwd(), "content", "apiSchema");
if (!fs.existsSync(schemasDir)) {
return NextResponse.json({ schemas: [] });
}
const files = fs.readdirSync(schemasDir);
const schemas = files
.filter((file) => file.endsWith(".json"))
.map((file) => {
const displayName = path.basename(file, ".json");
return {
id: displayName,
filename: file,
displayName,
apiSchema: JSON.parse(
fs.readFileSync(path.join(schemasDir, file), "utf-8")
).apiSchema,
};
});
return NextResponse.json({ schemas });
} catch (error) {
return NextResponse.json(
{ error: "Failed to read API schemas" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,92 @@
import fs from "node:fs";
import path from "node:path";
import { type NextRequest, NextResponse } from "next/server";
/**
* Recursively deletes all files and subdirectories in a directory
*/
function clearDirectoryRecursive(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
return; // Directory doesn't exist, nothing to clear
}
const items = fs.readdirSync(dirPath);
for (const item of items) {
const itemPath = path.join(dirPath, item);
const stats = fs.statSync(itemPath);
if (stats.isDirectory()) {
// Recursively clear subdirectory
clearDirectoryRecursive(itemPath);
// Remove the empty directory
fs.rmdirSync(itemPath);
} else {
// Remove file
fs.unlinkSync(itemPath);
}
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { directoryPath } = body;
if (!directoryPath) {
return NextResponse.json(
{ error: "Directory path is required" },
{ status: 400 }
);
}
// Security check: ensure the path is within the content directory
const contentDir = path.join(process.cwd(), "content");
const targetPath = path.join(contentDir, directoryPath);
const resolvedTargetPath = path.resolve(targetPath);
const resolvedContentDir = path.resolve(contentDir);
if (!resolvedTargetPath.startsWith(resolvedContentDir)) {
return NextResponse.json(
{ error: "Invalid directory path - must be within content directory" },
{ status: 400 }
);
}
// Check if we're in a development environment
if (process.env.NODE_ENV === "production") {
return NextResponse.json(
{
error: "Directory clearing is not allowed in production",
suggestion: "Use TinaCMS GraphQL mutations instead",
},
{ status: 403 }
);
}
// Clear the directory
clearDirectoryRecursive(targetPath);
// Recreate the directory (empty)
if (!fs.existsSync(targetPath)) {
fs.mkdirSync(targetPath, { recursive: true });
}
return NextResponse.json(
{
success: true,
message: `Directory ${directoryPath} cleared successfully`,
clearedPath: path.relative(process.cwd(), targetPath),
},
{ status: 200 }
);
} catch (error) {
return NextResponse.json(
{
error: "Failed to clear directory",
details: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,70 @@
import { sanitizeFileName } from "@/src/utils/sanitizeFilename";
import { createOrUpdateAPIReference } from "@/src/utils/tina/api-reference";
import type { TinaGraphQLClient } from "@/src/utils/tina/tina-graphql-client";
import type { EndpointData } from "./types";
const collection = "docs";
/**
* Generates a safe filename from endpoint data
*/
export function generateAPIEndpointFileName(endpoint: EndpointData): string {
const method = endpoint.method.toLowerCase();
const pathSafe = endpoint.path
.replace(/^\//, "") // Remove leading slash
.replace(/\//g, "-") // Replace slashes with dashes
.replace(/[{}]/g, "") // Remove curly braces
.replace(/[^\w-]/g, "") // Remove any non-word characters except dashes
.toLowerCase();
return `${method}-${pathSafe}`;
}
export async function generateMdxFiles(
groupData: {
tag: string;
schema: string;
endpoints: EndpointData[];
},
client: TinaGraphQLClient
): Promise<{ createdFiles: string[]; skippedFiles: string[] }> {
if (!groupData.endpoints?.length)
return { createdFiles: [], skippedFiles: [] };
const { tag, schema, endpoints } = groupData;
const basePath = `api-documentation/${sanitizeFileName(tag)}`;
const createdFiles: string[] = [];
const skippedFiles: string[] = [];
const errors: string[] = [];
for (const endpoint of endpoints) {
const fileName = generateAPIEndpointFileName(endpoint);
const relativePath = `${basePath}/${fileName}.mdx`;
try {
const result = await createOrUpdateAPIReference(
client,
relativePath,
collection,
endpoint,
schema
);
if (result === "created" || result === "updated") {
createdFiles.push(relativePath);
} else if (result === "skipped") {
skippedFiles.push(relativePath);
}
} catch (error: any) {
errors.push(
`Failed to handle ${endpoint.method} ${endpoint.path}: ${error.message}`
);
}
}
if (errors.length > 0) {
// biome-ignore lint/suspicious/noConsole: <explanation>
console.error("API Doc Generation Errors:\n", errors.join("\n"));
}
return { createdFiles, skippedFiles };
}

View File

@@ -0,0 +1,77 @@
import { TinaGraphQLClient } from "@/src/utils/tina/tina-graphql-client";
import { type NextRequest, NextResponse } from "next/server";
import { generateMdxFiles } from "./generate-mdx-files";
import type { GroupApiData } from "./types";
const isDev = process.env.NODE_ENV === "development";
export async function POST(request: NextRequest) {
try {
const { data } = await request.json();
const authHeader = request.headers.get("authorization");
if (!isDev && (!authHeader || !authHeader.startsWith("Bearer "))) {
return NextResponse.json(
{ error: "Missing Authorization token" },
{ status: 401 }
);
}
const token = authHeader?.replace("Bearer ", "");
const client = new TinaGraphQLClient(token || "");
const tabs = data?.tabs || [];
if (!Array.isArray(tabs)) {
return NextResponse.json(
{ error: "Invalid data format - expected tabs array" },
{ status: 400 }
);
}
const allCreatedFiles: string[] = [];
const allSkippedFiles: string[] = [];
for (const item of tabs) {
if (item._template === "apiTab") {
// Process API groups within this tab
for (const group of item.supermenuGroup || []) {
if (group._template === "groupOfApiReferences" && group.apiGroup) {
try {
// Parse the group data
const groupData: GroupApiData =
typeof group.apiGroup === "string"
? JSON.parse(group.apiGroup)
: group.apiGroup;
// Generate files for this group
const { createdFiles, skippedFiles } = await generateMdxFiles(
groupData,
client
);
allCreatedFiles.push(...createdFiles);
allSkippedFiles.push(...skippedFiles);
} catch (error) {
// Continue processing other groups
}
}
}
}
}
return NextResponse.json({
success: true,
message: `Processed ${tabs.length} navigation tabs`,
totalFilesCreated: allCreatedFiles.length,
createdFiles: allCreatedFiles,
skippedFiles: allSkippedFiles,
});
} catch (error) {
return NextResponse.json(
{
error: "Failed to process navigation API groups",
details: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,14 @@
export interface EndpointData {
id: string;
label: string;
method: string;
path: string;
summary: string;
description: string;
}
export interface GroupApiData {
schema: string;
tag: string;
endpoints: EndpointData[];
}

View File

@@ -0,0 +1,92 @@
"use client";
import { CopyPageDropdown } from "@/components/copy-page-dropdown";
import { BreadCrumbs } from "@/components/docs/breadcrumbs";
import { useNavigation } from "@/components/docs/layout/navigation-context";
import { OnThisPage } from "@/components/docs/on-this-page";
import MarkdownComponentMapping from "@/components/tina-markdown/markdown-component-mapping";
import { Pagination } from "@/components/ui/pagination";
import GitHubMetadata from "@/src/components/page-metadata/github-metadata";
import { formatDate, useTocListener } from "@/utils/docs";
import { tinaField } from "tinacms/dist/react";
import { TinaMarkdown } from "tinacms/dist/rich-text";
export default function Document({ props, tinaProps }) {
const { data } = tinaProps;
const navigationData = useNavigation();
const documentationData = data.docs;
const { pageTableOfContents } = props;
const formattedDate = formatDate(documentationData?.last_edited);
const previousPage = {
slug: documentationData?.previous?.id.slice(7, -4),
title: documentationData?.previous?.title,
};
const nextPage = {
slug: documentationData?.next?.id.slice(7, -4),
title: documentationData?.next?.title,
};
// Table of Contents Listener to Highlight Active Section
const { activeIds, contentRef } = useTocListener(documentationData);
return (
// 73.5% of 100% is ~ 55% of the screenwidth in parent div
// 26.5% of 100% is ~ 20% of the screenwidth in parent div
<div className="grid grid-cols-1 gap-8 xl:grid-cols-docs-layout">
<div
className={`mx-auto max-w-3xl w-full overflow-hidden ${
!documentationData?.tocIsHidden ? "xl:col-span-1" : ""
}`}
>
<div className="overflow-hidden break-words mx-8 mt-2 md:mt-0">
<BreadCrumbs navigationDocsData={navigationData} />
<div className="flex flex-row items-center justify-between w-full gap-2">
<h1
className="text-brand-primary my-4 font-heading text-4xl"
data-tina-field={tinaField(documentationData, "title")}
data-pagefind-meta="title"
>
{documentationData?.title
? documentationData.title.charAt(0).toUpperCase() +
documentationData.title.slice(1)
: documentationData?.title}
</h1>
<CopyPageDropdown className="self-end mb-2 md:mb-0" />
</div>
{props.hasGithubConfig && <GitHubMetadata />}
{/* CONTENT */}
<div
ref={contentRef}
data-tina-field={tinaField(documentationData, "body")}
className="mt-4 font-body font-light leading-normal tracking-normal"
>
<TinaMarkdown
content={documentationData?.body}
components={MarkdownComponentMapping}
/>
</div>
{formattedDate && (
<span className="text-md text-slate-500 font-body font-light">
{" "}
Last Edited: {formattedDate}
</span>
)}
<Pagination />
</div>
</div>
{/* DESKTOP TABLE OF CONTENTS */}
{documentationData?.tocIsHidden ? null : (
<div
className={
"sticky hidden xl:block top-4 h-fit mx-4 w-64 justify-self-end"
}
>
<OnThisPage pageItems={pageTableOfContents} activeids={activeIds} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { TinaClient } from "@/app/tina-client";
import settings from "@/content/siteConfig.json";
import { fetchTinaData } from "@/services/tina/fetch-tina-data";
import { GitHubMetadataProvider } from "@/src/components/page-metadata/github-metadata-context";
import GithubConfig from "@/src/utils/github-client";
import client from "@/tina/__generated__/client";
import { getTableOfContents } from "@/utils/docs";
import { getSeo } from "@/utils/metadata/getSeo";
import Document from ".";
const siteUrl =
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: settings.siteUrl;
export async function generateStaticParams() {
try {
let pageListData = await client.queries.docsConnection();
const allPagesListData = pageListData;
while (pageListData.data.docsConnection.pageInfo.hasNextPage) {
const lastCursor = pageListData.data.docsConnection.pageInfo.endCursor;
pageListData = await client.queries.docsConnection({
after: lastCursor,
});
allPagesListData.data.docsConnection.edges?.push(
...(pageListData.data.docsConnection.edges || [])
);
}
const pages =
allPagesListData.data.docsConnection.edges?.map((page) => {
const path = page?.node?._sys.path;
const slugWithoutExtension = path?.replace(/\.mdx$/, "");
const pathWithoutPrefix = slugWithoutExtension?.replace(
/^content\/docs\//,
""
);
const slugArray = pathWithoutPrefix?.split("/") || [];
return {
slug: slugArray,
};
}) || [];
return pages;
} catch (error) {
// biome-ignore lint/suspicious/noConsole: <explanation>
console.error("Error in generateStaticParams:", error);
return [];
}
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string[] }>;
}) {
const dynamicParams = await params;
const slug = dynamicParams?.slug?.join("/");
const { data } = await fetchTinaData(client.queries.docs, slug);
if (!data.docs.seo) {
data.docs.seo = {
__typename: "DocsSeo",
canonicalUrl: `${siteUrl}/tinadocs/docs/${slug}`,
};
} else if (!data.docs.seo?.canonicalUrl) {
data.docs.seo.canonicalUrl = `${siteUrl}/tinadocs/docs/${slug}`;
}
return getSeo(data.docs.seo, {
pageTitle: data.docs.title,
body: data.docs.body,
});
}
async function getData(slug: string) {
const data = await fetchTinaData(client.queries.docs, slug);
return data;
}
export default async function DocsPage({
params,
}: {
params: Promise<{ slug: string[] }>;
}) {
const dynamicParams = await params;
const slug = dynamicParams?.slug?.join("/");
const data = await getData(slug);
const pageTableOfContents = getTableOfContents(data?.data.docs.body);
const githubMetadata = GithubConfig.IsConfigured
? await GithubConfig.fetchMetadata(data?.data.docs.id)
: null;
return (
<GitHubMetadataProvider data={githubMetadata}>
<TinaClient
Component={Document}
props={{
query: data.query,
variables: data.variables,
data: data.data,
hasGithubConfig: GithubConfig.IsConfigured,
pageTableOfContents,
documentationData: data,
forceExperimental: data.variables.relativePath,
}}
/>
</GitHubMetadataProvider>
);
}

64
src/app/docs/page.tsx Normal file
View File

@@ -0,0 +1,64 @@
import { TinaClient } from "@/app/tina-client";
import settings from "@/content/siteConfig.json";
import { fetchTinaData } from "@/services/tina/fetch-tina-data";
import { GitHubMetadataProvider } from "@/src/components/page-metadata/github-metadata-context";
import GitHubClient from "@/src/utils/github-client";
import client from "@/tina/__generated__/client";
import { getTableOfContents } from "@/utils/docs";
import { getSeo } from "@/utils/metadata/getSeo";
import Document from "./[...slug]";
const siteUrl =
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: settings.siteUrl;
export async function generateMetadata() {
const slug = "index";
const { data } = await client.queries.docs({ relativePath: `${slug}.mdx` });
if (!data.docs.seo) {
data.docs.seo = {
__typename: "DocsSeo",
canonicalUrl: `${siteUrl}/tinadocs/docs`,
};
} else if (!data.docs.seo?.canonicalUrl) {
data.docs.seo.canonicalUrl = `${siteUrl}/tinadocs/docs`;
}
return getSeo(data.docs.seo, {
pageTitle: data.docs.title,
body: data.docs.body,
});
}
async function getData() {
const defaultSlug = "index";
const data = await fetchTinaData(client.queries.docs, defaultSlug);
return data;
}
export default async function DocsPage() {
const data = await getData();
const pageTableOfContents = getTableOfContents(data?.data.docs.body);
const githubMetadata = GitHubClient.IsConfigured
? await GitHubClient.fetchMetadata(data?.data.docs.id)
: null;
return (
<GitHubMetadataProvider data={githubMetadata}>
<TinaClient
Component={Document}
props={{
query: data.query,
variables: data.variables,
data: data.data,
pageTableOfContents,
hasGithubConfig: GitHubClient.IsConfigured,
documentationData: data,
forceExperimental: data.variables.relativePath,
}}
/>
</GitHubMetadataProvider>
);
}

62
src/app/error-wrapper.tsx Normal file
View File

@@ -0,0 +1,62 @@
"use client";
import Image from "next/image";
import Link from "next/link";
const ErrorWrapper = ({
errorConfig,
}: {
errorConfig: {
description: string;
title: string;
links: { linkText: string; linkUrl: string }[];
};
}) => {
return (
<div className="container mx-auto flex h-screen items-start justify-center">
<div className="grid grid-cols-1 items-center gap-8 py-24 md:grid-cols-2">
<div className="flex flex-col items-center text-center">
<div className="mb-7">
<h2 className="bg-gradient-to-r from-brand-secondary-gradient-start to-brand-secondary-gradient-end bg-clip-text font-heading text-6xl leading-normal h-fit text-transparent">
{errorConfig?.title ?? "Sorry, Friend."}
</h2>
<hr className="block h-[7px] w-full border-none bg-[url('/svg/hr.svg')] bg-[length:auto_100%] bg-no-repeat" />
<p className="-mb-1 block text-neutral-text font-thin text-md lg:text-lg lg:leading-normal">
{errorConfig?.description ??
"We couldn't find what you were looking for."}
</p>
</div>
<div className="flex flex-wrap gap-4">
{errorConfig?.links?.map(
(link) =>
(link?.linkUrl || link?.linkUrl === "") && (
<a
key={link.linkUrl}
href={link.linkUrl}
className="text-neutral-text-secondary shadow-sm hover:shadow-md outline outline-neutral-border-subtle hover:text-neutral-text rounded-md p-2 bg-neutral-background hover:bg-neutral-background-secondary"
>
<div>{link.linkText ?? "External Link 🔗"}</div>
</a>
)
)}
</div>
</div>
<div className="mx-auto max-w-[65vw] md:max-w-none">
<div className="relative aspect-square overflow-hidden flex items-center justify-center">
<Image
src={`${
process.env.NEXT_PUBLIC_BASE_PATH || ""
}/img/404-image.jpg`}
alt="404 Llama"
className="rounded-3xl object-cover"
width={364}
height={364}
/>
</div>
</div>
</div>
</div>
);
};
export default ErrorWrapper;

31
src/app/global-error.tsx Normal file
View File

@@ -0,0 +1,31 @@
"use client"; // Error boundaries must be Client Components
import ErrorWrapper from "./error-wrapper";
import "@/styles/global.css";
import RootLayout from "./layout";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<ErrorWrapper
errorConfig={{
title: "Sorry, Friend!",
description: "Something went wrong!",
links: [
{
linkText: "Return to docs",
linkUrl: "/docs",
},
{
linkText: "Try again",
linkUrl: "",
},
],
}}
/>
);
}

96
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,96 @@
import "@/styles/global.css";
import AdminLink from "@/components/ui/admin-link";
import { TailwindIndicator } from "@/components/ui/tailwind-indicator";
import { ThemeSelector } from "@/components/ui/theme-selector";
import settings from "@/content/settings/config.json";
import client from "@/tina/__generated__/client";
import { GoogleTagManager } from "@next/third-parties/google";
import { ThemeProvider } from "next-themes";
import { Inter, Roboto_Flex } from "next/font/google";
import { TabsLayout } from "@/components/docs/layout/tab-layout";
import type React from "react";
import { TinaClient } from "./tina-client";
const isDev = process.env.NODE_ENV === "development";
const body = Inter({ subsets: ["latin"], variable: "--body-font" });
const heading = Roboto_Flex({
subsets: ["latin"],
weight: ["400"],
style: ["normal"],
variable: "--heading-font",
});
const isThemeSelectorEnabled =
isDev || process.env.NEXT_PUBLIC_ENABLE_THEME_SELECTION === "true";
const theme = settings.selectedTheme || "default";
const gtmId = process.env.NEXT_PUBLIC_GTM_ID;
export default function RootLayout({
children = null,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`theme-${theme}`} suppressHydrationWarning>
<head>
<meta name="theme-color" content="#E6FAF8" />
<link rel="alternate" type="application/rss+xml" href="/rss.xml" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body className={`${body.variable} ${heading.variable}`}>
{!isDev && gtmId && (
<GoogleTagManager
gtmId={gtmId}
gtmScriptUrl="https://www.googletagmanager.com/gtm.js"
/>
)}
<ThemeProvider
attribute="class"
defaultTheme={theme}
enableSystem={true}
disableTransitionOnChange={false}
>
{isThemeSelectorEnabled && <ThemeSelector />}
<Content>
<DocsMenu>{children}</DocsMenu>
</Content>
</ThemeProvider>
</body>
</html>
);
}
const Content = ({ children }: { children?: React.ReactNode }) => (
<>
<AdminLink />
<TailwindIndicator />
<div className="font-sans flex min-h-screen flex-col bg-background-color">
<div className="flex flex-1 flex-col items-center">{children}</div>
</div>
</>
);
const DocsMenu = async ({ children }: { children?: React.ReactNode }) => {
// Fetch navigation data that will be shared across all docs pages
const navigationData = await client.queries.minimisedNavigationBarFetch({
relativePath: "docs-navigation-bar.json",
});
return (
<div className="relative flex flex-col w-full pb-2">
<TinaClient
props={{
query: navigationData.query,
variables: navigationData.variables,
data: navigationData.data,
children,
}}
Component={TabsLayout}
/>
</div>
);
};

22
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,22 @@
import ErrorWrapper from "./error-wrapper";
export default async function NotFound() {
return (
<div className="w-full flex flex-col md:flex-row gap-4 md:p-4 max-w-[2560px] mx-auto">
<main className="flex-1">
<ErrorWrapper
errorConfig={{
title: "Sorry, Friend!",
description: "We couldn't find what you were looking for.",
links: [
{
linkText: "Return to docs",
linkUrl: "/docs",
},
],
}}
/>
</main>
</div>
);
}

44
src/app/tina-client.tsx Normal file
View File

@@ -0,0 +1,44 @@
"use client";
import type React from "react";
import { useTina } from "tinacms/dist/react";
export type UseTinaProps = {
query: string;
variables: Record<string, unknown>;
data: Record<string, unknown>;
forceExperimental?: string;
};
export type TinaClientProps<T> = {
props: UseTinaProps & T & any;
Component: React.FC<{
tinaProps: { data: Record<string, unknown> };
props: {
query: string;
variables: Record<string, unknown>;
data: Record<string, unknown>;
pageTableOfContents: Record<string, unknown>;
documentationData: Record<string, unknown>;
};
}>;
};
export function TinaClient<T>({ props, Component }: TinaClientProps<T>) {
const { data } = props.forceExperimental
? useTina({
query: props.query,
variables: props.variables,
data: props.data,
experimental___selectFormByFormId() {
return `content/docs/${props.forceExperimental}`;
},
})
: useTina({
query: props.query,
variables: props.variables,
data: props.data,
});
return <Component tinaProps={{ data }} props={{ ...props }} />;
}

View File

@@ -0,0 +1,51 @@
export const actionsButtonTemplateFields = {
fields: [
{ name: "label", label: "Label", type: "string" },
{ name: "icon", label: "Icon", type: "boolean" },
{
name: "variant",
label: "Variant",
type: "string",
options: [
{ value: "default", label: "Seafoam" },
{ value: "blue", label: "Blue" },
{ value: "orange", label: "Orange" },
{ value: "white", label: "White" },
{ value: "ghost", label: "Ghost" },
{ value: "orangeWithBorder", label: "Orange with Border" },
{ value: "ghostBlue", label: "Ghost Blue" },
],
},
{
name: "size",
label: "Size",
type: "string",
options: [
{ value: "small", label: "Small" },
{ value: "medium", label: "Medium" },
{ value: "large", label: "Large" },
],
},
{ name: "url", label: "URL", type: "string" },
],
};
export const actionsButtonTemplate = {
label: "Actions",
name: "actions",
type: "object",
list: true,
ui: {
itemProps: (item) => {
return { label: item?.label };
},
defaultItem: {
variant: "default",
label: "Secondary Action",
icon: false,
size: "medium",
url: "/",
},
},
fields: actionsButtonTemplateFields.fields,
};

View File

@@ -0,0 +1,71 @@
import { FlushButton, LinkButton } from "@/components/ui/buttons";
import { sanitizeLabel } from "@/utils/sanitizeLabel";
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
import { tinaField } from "tinacms/dist/react";
export const Actions = ({ items, align = "left", flush = false }) => {
const isList = true;
const ActionButton = flush ? FlushButton : LinkButton;
return (
<>
<div
className={[
"items-center",
isList
? "flex flex-col sm:flex-row md:flex-row lg:flex-row"
: "flex flex-row",
align === "center" && "actionGroupCenter",
]
.filter(Boolean)
.join(" ")}
>
{items?.map((item) => {
const { variant, label, icon, url } = item;
{
const externalUrlPattern = /^((http|https|ftp):\/\/)/;
const external = externalUrlPattern.test(url);
const link = url || "#";
return (
<ActionButton
key={label}
id={sanitizeLabel(label)}
size={item.size ? item.size : "medium"}
link={link}
target={external ? "_blank" : "_self"}
color={variant}
data-tina-field={tinaField(item, "label")}
>
{label}
{icon && (
<ArrowLeftIcon className="-mr-1 -mt-1 ml-2 h-[1.125em] w-auto rotate-180 opacity-70" />
)}
</ActionButton>
);
}
})}
</div>
<style jsx>{`
.or-text {
margin: 0.5rem 1.5rem 0.5rem 0.75rem;
font-size: 1.125rem;
color: var(--color-secondary);
font-weight: bold;
}
.actionGroupCenter {
justify-content: center;
}
.icon-class {
display: inline-block;
fill: currentColor;
margin-left: 0.375em;
height: 1em;
width: auto;
transition: opacity ease-out 150ms;
}
`}</style>
</>
);
};

View File

@@ -0,0 +1,237 @@
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@radix-ui/react-dropdown-menu";
import copy from "copy-to-clipboard";
import htmlToMd from "html-to-md";
import type React from "react";
import { useEffect, useState } from "react";
import { FaCommentDots } from "react-icons/fa";
import {
MdArrowDropDown,
MdCheck,
MdContentCopy,
MdFilePresent,
} from "react-icons/md";
import { SiOpenai } from "react-icons/si";
interface CopyPageDropdownProps {
title?: string;
className?: string;
}
export const CopyPageDropdown: React.FC<CopyPageDropdownProps> = ({
title = "Documentation Page",
}) => {
const [copied, setCopied] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [markdownUrl, setMarkdownUrl] = useState<string | null>(null);
const [hasMounted, setHasMounted] = useState(false);
// Prevent hydration mismatch
useEffect(() => {
setHasMounted(true);
}, []);
useEffect(() => {
if (copied) {
const timeout = setTimeout(() => setCopied(false), 4000);
return () => clearTimeout(timeout);
}
}, [copied]);
const getCleanHtmlContent = (): HTMLElement | null => {
const element = document.getElementById("doc-content");
if (!element) {
alert("Unable to locate content for export.");
return null;
}
const clone = element.cloneNode(true) as HTMLElement;
const elementsToRemove = clone.querySelectorAll("[data-exclude-from-md]");
for (const el of elementsToRemove) {
el.remove();
}
return clone;
};
const convertToMarkdown = (html: string): string => {
const rawMd = htmlToMd(html);
return rawMd.replace(/<button>Copy<\/button>\n?/g, "");
};
const handleCopyPage = () => {
const htmlContent = getCleanHtmlContent()?.innerHTML || "";
const markdown = convertToMarkdown(htmlContent);
const referenceSection =
"\n\n---\nAsk questions about this page:\n- [Open in ChatGPT](https://chat.openai.com/chat)\n- [Open in Claude](https://claude.ai/)";
const finalContent = `${title}\n\n${markdown}${referenceSection}`;
copy(finalContent);
setCopied(true);
};
const exportMarkdownAndOpen = async (): Promise<string | null> => {
if (markdownUrl) return markdownUrl;
const htmlContent = getCleanHtmlContent()?.innerHTML || "";
const markdown = convertToMarkdown(htmlContent);
const pathname = window.location.pathname.replace(/^\//, "") || "index";
const filename = `${pathname}.md`;
try {
// Include basePath if configured
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || "";
const apiUrl = `${basePath}/api/export-md`;
const res = await fetch(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: markdown, filename }),
});
if (!res.ok) throw new Error("Export failed");
const data = await res.json();
const fullUrl = `${window.location.origin}${data.url}`;
setMarkdownUrl(fullUrl);
return fullUrl;
} catch (err) {
alert("Failed to export Markdown.");
return null;
}
};
const handleViewMarkdown = async () => {
const url = await exportMarkdownAndOpen();
if (url) window.open(url, "_blank");
};
const openInLLM = async (generateUrl: (url: string) => string) => {
const url = await exportMarkdownAndOpen();
if (url) window.open(generateUrl(url), "_blank", "noopener,noreferrer");
};
if (!hasMounted) return null;
return (
<div
className="mb-2 inline-flex rounded-lg overflow-hidden h-fit lg:mb-0 brand-glass-gradient text-neutral-text-secondary shadow-sm item-center w-fit ml-auto border border-neutral-border-subtle/50"
data-exclude-from-md
>
{/* Primary copy button */}
<button
onClick={handleCopyPage}
className={`cursor-pointer flex items-center px-1.5 py-0.5 ${
copied
? "text-neutral-text-secondary"
: "text-brand-secondary-dark-dark"
}`}
type="button"
>
<span>
{copied ? (
<span className="flex items-center gap-2">
<MdCheck className="w-4 h-4" />
<span>Copied</span>
</span>
) : (
<span className="flex items-center gap-2 py-1 lg:py-0 text-neutral-text-secondary`">
<MdContentCopy className="w-4 h-4" />
<span className="hidden lg:block">Copy</span>
</span>
)}
</span>
</button>
{/* Dropdown */}
<DropdownMenu onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<button
className="cursor-pointer px-3 rounded-r-lg focus:outline-none"
type="button"
>
<MdArrowDropDown
className={`w-4 h-4 text-brand-secondary-dark-dark transition-transform duration-200 ${
isOpen ? "rotate-180" : "rotate-0"
}`}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="z-50 mt-2 w-72 rounded-lg bg-neutral-background dark:border-1 dark:border-neutral-border-subtle shadow-md"
sideOffset={0}
align="end"
>
{[
{
icon: (
<MdContentCopy className="w-4 h-4 text-neutral-text-secondary" />
),
label: "Copy page",
description: "Copy page as Markdown for LLMs",
onClick: handleCopyPage,
},
{
icon: (
<MdFilePresent className="w-4 h-4 text-neutral-text-secondary" />
),
label: "View as Markdown",
description: "View this page as plain text",
onClick: handleViewMarkdown,
},
{
icon: (
<SiOpenai className="w-4 h-4 text-neutral-text-secondary" />
),
label: "Open in ChatGPT",
description: "Ask questions about this page",
onClick: () =>
openInLLM(
(url) =>
`https://chat.openai.com/?hints=search&q=Read%20from%20${encodeURIComponent(
url
)}%20so%20I%20can%20ask%20questions%20about%20it.`
),
},
{
icon: (
<FaCommentDots className="w-4 h-4 text-neutral-text-secondary" />
),
label: "Open in Claude",
description: "Ask questions about this page",
onClick: () =>
openInLLM(
(url) =>
`https://claude.ai/?q=Read%20from%20${encodeURIComponent(
url
)}%20so%20I%20can%20ask%20questions%20about%20it.`
),
},
].map(({ icon, label, description, onClick }) => (
<DropdownMenuItem
key={label}
className="flex items-start gap-3 p-2 text-sm text-neutral hover:bg-neutral-background-secondary focus:outline-none first:rounded-t-lg last:rounded-b-lg cursor-pointer"
onClick={onClick}
>
<span className="flex items-center justify-center w-8 h-8 border-1 border-neutral-border-subtle rounded-md">
{icon}
</span>
<span className="flex flex-col">
<span className="font-medium text-neutral-text">{label}</span>
<span className="text-xs text-neutral-text-secondary font-light">
{description}
</span>
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};

View File

@@ -0,0 +1,213 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import React from "react";
interface BreadcrumbItem {
title: string;
url?: string;
}
export const BreadCrumbs = ({
navigationDocsData,
}: {
navigationDocsData: any;
}) => {
// Helper function to extract a clean URL path from a slug object
const getUrlFromSlug = (slug: any): string => {
if (typeof slug === "string") {
// Handle special case for docs homepage
if (slug === "content/docs/index.mdx") {
return "/docs";
}
return slug;
}
if (slug && typeof slug === "object" && slug._sys?.relativePath) {
// Handle special case for docs homepage
if (slug._sys.relativePath === "index.mdx") {
return "/docs";
}
return `/docs/${slug._sys.relativePath.replace(/\.mdx$/, "")}`;
}
if (slug && typeof slug === "object" && slug.id) {
// Handle special case for docs homepage
if (slug.id === "content/docs/index.mdx") {
return "/docs";
}
return slug.id.replace(/^content\//, "/").replace(/\.mdx$/, "");
}
return "";
};
// Find the first page URL in a list of items (recursively)
const findFirstPageUrl = (items: any[]): string | null => {
if (!Array.isArray(items)) return null;
for (const item of items) {
// If this item has a slug, it's a page - return its URL
if (item.slug) {
return getUrlFromSlug(item.slug);
}
// If this item has nested items, search recursively
if (item.items && Array.isArray(item.items)) {
const nestedUrl = findFirstPageUrl(item.items);
if (nestedUrl) return nestedUrl;
}
}
return null;
};
// Recursive function to search through nested items and return breadcrumb items
const searchInItems = (
items: any[],
currentPath: string
): BreadcrumbItem[] => {
if (!Array.isArray(items) || !currentPath) return [];
for (const item of items) {
if (!item) continue;
// Check if this item has a slug that matches the current page
if (item.slug) {
const itemUrl = getUrlFromSlug(item.slug);
if (itemUrl) {
// Normalize URLs for comparison (remove trailing slashes)
const normalizedCurrentPath = currentPath.replace(/\/$/, "") || "/";
const normalizedItemUrl = itemUrl.replace(/\/$/, "") || "/";
if (normalizedCurrentPath === normalizedItemUrl) {
// This is the current page - no URL needed
const title = item.slug?.title || item.title || "Untitled";
return [{ title }];
}
}
}
// If this item has nested items, search recursively
if (item.items && Array.isArray(item.items)) {
const nestedResult = searchInItems(item.items, currentPath);
if (nestedResult.length > 0) {
// Found the current page in nested items
// Add this item's title with a link to its first page
const firstPageUrl = findFirstPageUrl(item.items);
return [
{
title: item.title || "Untitled",
url: firstPageUrl || undefined,
},
...nestedResult,
];
}
}
}
return [];
};
// Function to find the breadcrumb trail for the current page
const findBreadcrumbTrail = (
navigationData: any,
currentPath: string
): BreadcrumbItem[] => {
const trail: BreadcrumbItem[] = [];
if (!navigationData || !currentPath) {
return trail;
}
// Check if navigationData has a 'data' property (formatted navigation structure)
const tabsData = navigationData.data || [];
if (!Array.isArray(tabsData)) {
return trail;
}
// Search through all tabs to find the current page
for (const tab of tabsData) {
if (!tab || !tab.items || !Array.isArray(tab.items)) {
continue;
}
// Search through supermenu groups in this tab
for (const supermenuGroup of tab.items) {
if (
!supermenuGroup ||
!supermenuGroup.items ||
!Array.isArray(supermenuGroup.items)
) {
continue;
}
// Check if the current page exists in this supermenu group
const foundInGroup = searchInItems(supermenuGroup.items, currentPath);
if (foundInGroup.length > 0) {
// Add the supermenu group title with link to its first page
if (supermenuGroup.title) {
const firstPageUrl = findFirstPageUrl(supermenuGroup.items);
trail.push({
title: supermenuGroup.title,
url: firstPageUrl || undefined,
});
}
// Add any nested group titles and the final page title
trail.push(...foundInGroup);
return trail; // Found it, return early
}
}
}
return trail;
};
const currentPath = usePathname();
const breadcrumbs = findBreadcrumbTrail(navigationDocsData, currentPath);
if (!navigationDocsData || breadcrumbs.length === 0) {
return null;
}
return (
<nav aria-label="Breadcrumb" className="mb-2">
<ol className="flex items-center space-x-2 text-sm text-brand-primary-light">
{breadcrumbs.map((crumb, index) => {
const isLast = index === breadcrumbs.length - 1;
const isClickable = !isLast && crumb.url;
return (
<li key={index} className="flex items-center">
{index > 0 && (
<span className="mx-2 text-brand-primary" aria-hidden="true">
</span>
)}
{isClickable ? (
<Link
href={crumb?.url || "/"}
className="text-sm uppercase text-neutral-text-secondary hover:text-brand-primary transition-all duration-300 cursor-pointer"
>
{crumb.title}
</Link>
) : (
<span
className={`text-sm uppercase tracking-wide ${
isLast
? "font-medium text-neutral-text"
: "text-neutral-text-secondary"
}`}
>
{crumb.title}
</span>
)}
</li>
);
})}
</ol>
</nav>
);
};

View File

@@ -0,0 +1,13 @@
export const Body = ({
navigationDocsData,
children,
}: {
navigationDocsData: any;
children: React.ReactNode;
}) => {
return (
<div data-pagefind-body id="doc-content">
{children}
</div>
);
};

View File

@@ -0,0 +1,51 @@
"use client";
import { useTheme } from "next-themes";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
interface NavbarLogoProps {
navigationDocsData: any;
}
export const NavbarLogo = ({ navigationDocsData }: NavbarLogoProps) => {
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const lightLogo = navigationDocsData[0]?.lightModeLogo;
const darkLogo = navigationDocsData[0]?.darkModeLogo || lightLogo;
return (
<Link href="/" className="flex items-center">
<div className="relative md:w-[120px] w-[90px] h-[40px]">
{mounted ? (
<>
<Image
src={resolvedTheme === "dark" ? darkLogo : lightLogo}
alt="Logo"
fill
className="object-contain"
priority
sizes="(max-width: 768px) 90px, 120px"
/>
{/* Preload the other logo */}
<Image
src={resolvedTheme === "dark" ? lightLogo : darkLogo}
alt=""
fill
className="hidden"
priority
/>
</>
) : (
<div className="w-full h-full animate-pulse opacity-20" />
)}
</div>
</Link>
);
};

View File

@@ -0,0 +1,27 @@
"use client";
import { type ReactNode, createContext, useContext } from "react";
const NavigationContext = createContext<any>(null);
export const NavigationProvider = ({
children,
navigationData,
}: {
children?: ReactNode;
navigationData: any;
}) => {
return (
<NavigationContext.Provider value={navigationData}>
{children}
</NavigationContext.Provider>
);
};
export const useNavigation = () => {
const context = useContext(NavigationContext);
if (!context) {
throw new Error("useNavigation must be used within a NavigationProvider");
}
return context;
};

View File

@@ -0,0 +1,50 @@
"use client";
import * as Tabs from "@radix-ui/react-tabs";
import { useEffect, useState } from "react";
import { NavigationSideBar } from "../../navigation/navigation-sidebar";
export const Sidebar = ({
tabs,
}: {
tabs: { label: string; content: any }[];
}) => {
const [currentIndex, setCurrentIndex] = useState(0);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
const handleTabChange = (e: CustomEvent) => {
const newValue = e.detail.value;
setCurrentIndex(Number.parseInt(newValue));
};
window.addEventListener("tabChange" as any, handleTabChange);
return () => {
window.removeEventListener("tabChange" as any, handleTabChange);
};
}, []);
return (
<div className="border border-neutral-border/50 sticky hidden brand-glass-gradient lg:block mr-4 min-h-[calc(100vh-8rem)] h-fit w-80 p-4 ml-8 top-4 rounded-2xl dark:border dark:border-neutral-border-subtle/30 shadow-xl flex-shrink-0">
<div className="relative w-full overflow-x-hidden">
<div
className="flex transition-transform duration-300 ease-in-out"
style={
isMounted
? { transform: `translateX(-${currentIndex * 100}%)` }
: undefined
}
>
{tabs.map((tab) => (
<div key={tab.label} className="w-full flex-shrink-0">
<Tabs.Content value={tab.label}>
<NavigationSideBar tableOfContents={tab?.content} />
</Tabs.Content>
</div>
))}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,89 @@
"use client";
import { formatNavigationData } from "@/src/utils/docs/navigation/documentNavigation";
import type { NavigationBarData } from "@/src/utils/docs/navigation/documentNavigation";
import * as Tabs from "@radix-ui/react-tabs";
import { usePathname } from "next/navigation";
import React from "react";
import { Body } from "./body";
import { NavigationProvider } from "./navigation-context";
import { Sidebar } from "./sidebar";
import { TopNav } from "./top-nav";
import { findTabWithPath } from "./utils";
export const TabsLayout = ({
props: { children },
tinaProps,
}: {
props: {
children: React.ReactNode;
};
tinaProps: any;
}) => {
const [navigationDocsData, setNavigationDocsData] = React.useState({});
const [tabs, setTabs] = React.useState([]);
const [selectedTab, setSelectedTab] = React.useState();
const [objectOfSelectedTab, setObjectOfSelectedTab] = React.useState();
const pathname = usePathname();
React.useEffect(() => {
const formattedNavData = formatNavigationData(
tinaProps.data as NavigationBarData,
false
);
setNavigationDocsData(formattedNavData);
const tabs = formattedNavData.data.map((tab) => ({
label: tab.title,
content: tab,
__typename: tab.__typename,
}));
setTabs(tabs);
setSelectedTab(tabs[0]);
setObjectOfSelectedTab(tabs[0]);
}, [tinaProps.data]);
React.useEffect(() => {
// Find the tab that contains the current path
if (!tabs.length || !pathname) return;
const initialTab = findTabWithPath(tabs, pathname);
setSelectedTab(initialTab);
// Dispatch initial tab change with index
const initialIndex = tabs.findIndex((tab) => tab.label === initialTab);
window.dispatchEvent(
new CustomEvent("tabChange", {
detail: { value: initialIndex.toString() },
})
);
}, [tabs, pathname]);
const handleTabChange = (value: string) => {
setSelectedTab(value);
setObjectOfSelectedTab(value);
const newIndex = tabs.findIndex((tab) => tab.label === value);
window.dispatchEvent(
new CustomEvent("tabChange", { detail: { value: newIndex.toString() } })
);
};
return (
<Tabs.Root
value={selectedTab}
onValueChange={handleTabChange}
className="flex flex-col w-full"
>
<TopNav tabs={tabs} navigationDocsData={navigationDocsData} />
<NavigationProvider navigationData={navigationDocsData}>
<div className="w-full flex flex-col md:flex-row gap-4 md:p-4 max-w-[2560px] mx-auto">
<Sidebar tabs={tabs} />
<main className="flex-1">
<Body
navigationDocsData={objectOfSelectedTab?.content}
children={children}
/>
</main>
</div>
</NavigationProvider>
</Tabs.Root>
);
};

View File

@@ -0,0 +1,129 @@
import { MobileNavSidebar } from "@/components/navigation/mobile-navigation-sidebar";
import * as Tabs from "@radix-ui/react-tabs";
import Link from "next/link";
import type React from "react";
import { useState } from "react";
import { BsThreeDotsVertical } from "react-icons/bs";
import { Search } from "../../search-docs/search";
import LightDarkSwitch from "../../ui/light-dark-switch";
import { NavbarLogo } from "./navbar-logo";
export const TopNav = ({
tabs,
navigationDocsData,
}: {
tabs: { label: string; content: any }[];
navigationDocsData: any;
}) => {
const ctaButtons = navigationDocsData.ctaButtons;
const hasButtons = ctaButtons && (ctaButtons.button1 || ctaButtons.button2);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const getButtonClasses = (variant: string | undefined) => {
switch (variant) {
case "primary-background":
return "bg-brand-primary text-neutral-surface hover:bg-brand-primary-hover";
case "secondary-background":
return "bg-brand-secondary text-neutral-text hover:bg-brand-secondary-hover";
case "primary-outline":
return "border border-brand-primary text-brand-primary hover:bg-brand-primary/10";
case "secondary-outline":
return "border border-brand-secondary text-brand-secondary hover:bg-brand-secondary/10";
default:
return "bg-brand-primary text-neutral-surface hover:bg-brand-primary-hover";
}
};
return (
<div className="border border-neutral-border/50 mb-2 md:mb-4 w-full lg:px-8 py-1 dark:bg-glass-gradient-end dark:border-b dark:border-neutral-border-subtle/60 shadow-md/5">
<div className="max-w-[2560px] mx-auto flex items-center justify-between lg:py-0 py-2">
<div className="flex">
<NavbarLogo navigationDocsData={[navigationDocsData]} />
<Tabs.List className="lg:flex hidden">
{tabs.map((tab) => (
<Tabs.Trigger
key={tab.label}
value={tab.label}
className="px-1 text-lg relative text-brand-primary-contrast mx-4 focus:text-brand-secondary-hover cursor-pointer font-semibold data-[state=active]:text-brand-primary-text after:content-[''] after:absolute after:bottom-1.5 after:left-0 after:h-0.25 after:bg-brand-primary-text after:transition-all after:duration-300 after:ease-out data-[state=active]:after:w-full after:w-0"
>
{tab.label || "Untitled Tab"}
</Tabs.Trigger>
))}
</Tabs.List>
</div>
<div className="flex-1 flex justify-center">
<Search />
</div>
<div className="flex items-center gap-4">
{hasButtons && (
<>
<div className="hidden lg:flex gap-2">
{ctaButtons.button1?.label && ctaButtons.button1?.link && (
<Link
href={ctaButtons.button1.link}
target="_blank"
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap ${getButtonClasses(
ctaButtons.button1.variant
)}`}
>
{ctaButtons.button1.label}
</Link>
)}
{ctaButtons.button2?.label && ctaButtons.button2?.link && (
<Link
href={ctaButtons.button2.link}
target="_blank"
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap ${getButtonClasses(
ctaButtons.button2.variant
)}`}
>
{ctaButtons.button2.label}
</Link>
)}
</div>
<div className="lg:hidden relative">
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="p-2 hover:bg-neutral-background-secondary rounded-md"
type="button"
>
<BsThreeDotsVertical className="size-5 text-brand-secondary-contrast" />
</button>
{isDropdownOpen && (
<div className="absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-neutral-background border border-neutral-border-subtle z-10">
<div className="py-1">
{ctaButtons.button1?.label &&
ctaButtons.button1?.link && (
<Link
href={ctaButtons.button1.link}
className="block px-4 py-2 text-sm text-neutral-text hover:bg-neutral-background-secondary"
onClick={() => setIsDropdownOpen(false)}
>
{ctaButtons.button1.label}
</Link>
)}
{ctaButtons.button2?.label &&
ctaButtons.button2?.link && (
<Link
href={ctaButtons.button2.link}
className="block px-4 py-2 text-sm text-neutral-text hover:bg-neutral-background-secondary"
onClick={() => setIsDropdownOpen(false)}
>
{ctaButtons.button2.label}
</Link>
)}
</div>
</div>
)}
</div>
</>
)}
<MobileNavSidebar tocData={tabs} />
<div className="w-full hidden lg:flex justify-end">
<LightDarkSwitch />
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,27 @@
import { hasNestedSlug } from "@/components/navigation/navigation-items/utils";
import { hasMatchingApiEndpoint } from "@/components/navigation/navigation-items/utils";
interface Tab {
label: string;
content: {
items: any[];
__typename?: string;
};
}
export const findTabWithPath = (tabs: Tab[], path: string) => {
for (const tab of tabs) {
if (tab.content?.items && hasNestedSlug(tab.content?.items, path)) {
return tab.label;
}
if (
tab.content?.__typename === "NavigationBarTabsApiTab" &&
hasMatchingApiEndpoint(tab.content?.items, path)
) {
return tab.label;
}
}
return tabs[0]?.label;
};

View File

@@ -0,0 +1,238 @@
"use client";
import { formatHeaderId } from "@/utils/docs";
import { useMotionValueEvent, useScroll } from "motion/react";
import type React from "react";
import { useEffect, useRef, useState } from "react";
interface OnThisPageProps {
pageItems: Array<{ type: string; text: string }>;
activeids: string[];
}
const PAGE_TOP_SCROLL_THRESHOLD = 0.05;
export const generateMarkdown = (
tocItems: Array<{ type: string; text: string }>
) => {
return tocItems
.map((item) => {
const anchor = formatHeaderId(item.text);
const prefix = item.type === "h3" ? " " : "";
return `${prefix}- [${item.text}](#${anchor})`;
})
.join("\n");
};
export function getIdSyntax(text: string, index?: number) {
const baseId = text
.toLowerCase()
.replace(/ /g, "-")
.replace(/[^a-z0-9\-]/g, "");
return index !== undefined ? `${baseId}-${index}` : baseId;
}
export const OnThisPage = ({ pageItems }: OnThisPageProps) => {
const tocWrapperRef = useRef<HTMLDivElement>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const [isUserScrolling, setIsUserScrolling] = useState(false);
const { scrollYProgress } = useScroll();
useEffect(() => {
if (pageItems && pageItems.length > 0) {
// Start with Overview active (null) when page loads
setActiveId(null);
}
}, [pageItems]);
useMotionValueEvent(scrollYProgress, "change", (latest) => {
if (pageItems.length === 0 || isUserScrolling) return;
// If we're at the very top, show Overview as active
if (latest < PAGE_TOP_SCROLL_THRESHOLD) {
setActiveId(null);
return;
}
// Get header positions and normalize them to match the motion value (0-1)
const documentHeight =
document.documentElement.scrollHeight - window.innerHeight;
let newActiveId: string | null = null;
// Build list of headers with actual positions
const headersWithPositions = pageItems
.map((item, index) => {
const headerId = formatHeaderId(item.text);
const element = headerId ? document.getElementById(headerId) : null;
return {
id: getIdSyntax(item.text, index),
element,
offsetTop: element ? element.offsetTop : 0,
index,
};
})
.filter((header) => header.element);
// Calculate raw normalized positions, then rescale to ensure all headers are reachable
const rawPositions = headersWithPositions.map(
(h) => h.offsetTop / documentHeight
);
const maxRawPosition = Math.max(...rawPositions);
const scaleFactor = maxRawPosition > 0.95 ? 0.95 / maxRawPosition : 1;
const headers = headersWithPositions.map((header, idx) => ({
...header,
normalizedPosition: rawPositions[idx] * scaleFactor,
}));
// Find the active header based on normalized scroll position
for (let i = headers.length - 1; i >= 0; i--) {
const header = headers[i];
if (header.normalizedPosition <= latest) {
newActiveId = header.id;
break;
}
}
// If no header is found, use the first one if we're past the top threshold
if (
!newActiveId &&
headers.length > 0 &&
latest > PAGE_TOP_SCROLL_THRESHOLD
) {
newActiveId = headers[0].id;
}
setActiveId(newActiveId);
});
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleLinkClick = (
e: React.MouseEvent<HTMLAnchorElement>,
id: string,
fragment: string
) => {
e.preventDefault();
const element = document.getElementById(fragment);
if (element) {
window.scrollTo({
top: element.offsetTop - 45,
behavior: "smooth",
});
window.history.pushState(null, "", `#${fragment}`);
setActiveId(id);
setIsUserScrolling(true);
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
scrollTimeoutRef.current = setTimeout(() => {
setIsUserScrolling(false);
}, 1000);
}
};
const handleBackToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
window.history.pushState(null, "", window.location.pathname);
setActiveId(null);
setIsUserScrolling(true);
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
scrollTimeoutRef.current = setTimeout(() => {
setIsUserScrolling(false);
}, 1000);
};
if (!pageItems || pageItems.length === 0) {
return null;
}
return (
<div
className="mb-[-0.375rem] flex-auto break-words whitespace-normal overflow-wrap-break-word pt-6"
data-exclude-from-md
>
<div
className={
"block w-full leading-5 h-auto transition-all duration-400 ease-out max-h-0 overflow-hidden lg:max-h-none"
}
>
<div
ref={tocWrapperRef}
className="max-h-[70vh] py-2 2xl:max-h-[75vh]"
style={{
scrollbarWidth: "none",
msOverflowStyle: "none",
wordWrap: "break-word",
whiteSpace: "normal",
overflowWrap: "break-word",
WebkitMaskImage:
"linear-gradient(to bottom, transparent, black 5%, black 95%, transparent)",
maskImage:
"linear-gradient(to bottom, transparent, black 5%, black 95%, transparent)",
WebkitMaskRepeat: "no-repeat",
maskRepeat: "no-repeat",
}}
>
<div className="hidden lg:flex gap-2 font-light group">
<div
className={`border-r border-1 ${
activeId === null
? "border-brand-primary"
: "border-neutral-border-subtle group-hover:border-neutral-border"
}`}
/>
<button
type="button"
onClick={handleBackToTop}
className={`pl-2 py-1.5 ${
activeId === null
? "text-brand-primary"
: "group-hover:text-neutral-text text-neutral-text-secondary"
} text-left`}
>
Overview
</button>
</div>
{pageItems.map((item, index) => {
const uniqueId = getIdSyntax(item.text, index);
return (
<div className="flex gap-2 font-light group" key={uniqueId}>
<div
className={`border-r border-1 ${
activeId === uniqueId
? "border-brand-primary"
: "border-neutral-border-subtle group-hover:border-neutral-border"
}`}
/>
<a
href={`#${uniqueId}`}
onClick={(e) =>
handleLinkClick(
e,
uniqueId,
formatHeaderId(item.text) || ""
)
}
className={`${item.type === "h3" ? "pl-6" : "pl-2"} py-1.5 ${
activeId === uniqueId
? "text-brand-primary"
: "group-hover:text-neutral-text text-neutral-text-secondary"
}`}
>
{item.text}
</a>
</div>
);
})}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,93 @@
import { Bars3Icon } from "@heroicons/react/24/outline";
import { useEffect, useRef, useState } from "react";
import { getIdSyntax } from "./on-this-page";
const TableOfContentsItems = ({ tocData }) => {
const handleLinkClick = (
e: React.MouseEvent<HTMLAnchorElement>,
id: string
) => {
e.preventDefault();
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
window.history.pushState(null, "", `#${id}`);
}
};
return (
<div className="animate-fade-down animate-duration-300 absolute z-10 mt-4 max-h-96 w-full overflow-y-scroll rounded-lg bg-white p-6 shadow-lg">
{tocData?.map((item) => (
<div
className="flex gap-2 font-light group"
key={getIdSyntax(item.text)}
>
<div
className={`border-r border-1 border-gray-200 group-hover:border-neutral-500
`}
/>
<a
href={`#${getIdSyntax(item.text)}`}
onClick={(e) => handleLinkClick(e, getIdSyntax(item.text))}
className={`${
item.type === "h3" ? "pl-4" : "pl-2"
} py-1.5 text-gray-400 group-hover:text-black`}
>
{item.text}
</a>
</div>
))}
</div>
);
};
export const TableOfContentsDropdown = ({ tocData }) => {
const [isTableOfContentsOpen, setIsTableOfContentsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const handleClickOutside = (event) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target)
) {
setIsTableOfContentsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
return (
<div data-exclude-from-md>
{tocData?.tocData?.length !== 0 && (
<div className="w-full py-6" ref={containerRef}>
<div
className="cursor-pointer rounded-lg border-neutral-border brand-glass-gradient px-4 py-2 shadow-lg"
onClick={() => setIsTableOfContentsOpen(!isTableOfContentsOpen)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsTableOfContentsOpen(!isTableOfContentsOpen);
}
}}
>
<span className="flex items-center space-x-2">
<Bars3Icon className="size-5 text-brand-primary" />
<span className="py-1 text-neutral-text">On this page</span>
</span>
</div>
{isTableOfContentsOpen && (
<div className="relative w-full">
<TableOfContentsItems tocData={tocData} />
</div>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,12 @@
<svg
viewBox="0 0 49 68"
fill="inherit"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="title desc"
>
<title>Tina</title>
<desc>A proud llama</desc>
<path d="M31.4615 30.1782C34.763 27.4475 36.2259 11.3098 37.6551 5.50906C39.0843 -0.291715 44.995 0.00249541 44.995 0.00249541C44.995 0.00249541 43.4605 2.67299 44.0864 4.66584C44.7123 6.65869 49 8.44005 49 8.44005L48.0752 10.8781C48.0752 10.8781 46.1441 10.631 44.995 12.9297C43.8459 15.2283 45.7336 37.9882 45.7336 37.9882C45.7336 37.9882 38.8271 51.6106 38.8271 57.3621C38.8271 63.1136 41.5495 67.9338 41.5495 67.9338H37.7293C37.7293 67.9338 32.1252 61.2648 30.9759 57.9318C29.8266 54.5988 30.2861 51.2658 30.2861 51.2658C30.2861 51.2658 24.1946 50.921 18.7931 51.2658C13.3915 51.6106 9.78922 56.2539 9.13908 58.8512C8.48894 61.4486 8.21963 67.9338 8.21963 67.9338H5.19906C3.36057 62.2603 1.90043 60.2269 2.69255 57.3621C4.88665 49.4269 4.45567 44.9263 3.94765 42.9217C3.43964 40.9172 0 39.1676 0 39.1676C1.68492 35.7349 3.4048 34.0854 10.8029 33.9133C18.201 33.7413 28.1599 32.9088 31.4615 30.1782Z" />
<path d="M12.25 57.03C12.25 57.03 13.0305 64.2533 17.1773 67.9342H20.7309C17.1773 63.9085 16.7897 53.415 16.7897 53.415C14.9822 54.0035 12.4799 56.1106 12.25 57.03Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,29 @@
export const TRANSITION_DURATION = 300;
export const PADDING_LEVELS = {
default: {
left: "0.675rem",
top: "0.125rem",
bottom: "0.125rem",
right: "0.675rem",
},
level0: {
left: "0.75rem",
top: "0.25rem",
bottom: "0.125rem",
right: "0.75rem",
},
};
export const FONT_SIZES = {
xl: "text-xl",
base: "text-base",
small: "text-[15px]",
};
export const FONT_WEIGHTS = {
light: "font-light",
normal: "font-sans",
medium: "font-[500]",
semibold: "font-semibold",
bold: "font-bold",
};

View File

@@ -0,0 +1,42 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { NavigationToggle } from "./navigation-toggle";
import { NavigationDropdownContent } from "./navigation-toggle";
export const MobileNavSidebar = ({ tocData }: { tocData: any }) => {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const toggleDropdown = () => setIsOpen((prev) => !prev);
const closeDropdown = () => setIsOpen(false);
// Handle outside click
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
closeDropdown();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
});
return (
<div className="flex items-center" ref={containerRef}>
<NavigationToggle onToggle={toggleDropdown} />
{isOpen && (
<NavigationDropdownContent
tocData={Array.isArray(tocData) ? tocData : []}
onClose={closeDropdown}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,88 @@
import { getUrl } from "@/utils/get-url";
import React from "react";
import { titleCase } from "title-case";
import { NavLevel } from "./nav-level";
import type { ApiEndpoint, DocsNavProps } from "./types";
import { getEndpointSlug, getTagSlug, processApiGroups } from "./utils";
export const ApiNavigationItems: React.FC<
DocsNavProps & { __typename: string }
> = ({ navItems, __typename, onNavigate }) => {
const navListElem = React.useRef(null);
// Process API groups from navigation items
const { normalDocs, apiGroups } = React.useMemo(
() => processApiGroups(navItems),
[navItems]
);
// Ensure apiGroups is not undefined and has the correct type
const safeApiGroups: Record<string, ApiEndpoint[]> = apiGroups || {};
const processedApiGroups = React.useMemo(() => {
return Object.entries(safeApiGroups).map(([tag, endpoints]) => {
return {
title: titleCase(tag),
items: (endpoints || []).map((endpoint) => ({
title: endpoint.summary,
slug: `/docs/api-documentation/${getTagSlug(tag)}/${getEndpointSlug(
endpoint.method,
endpoint.path
)}`,
verb: endpoint.method.toLowerCase(),
endpoint_slug: getEndpointSlug(endpoint.method, endpoint.path),
})),
};
});
}, [safeApiGroups]);
return (
<div
className="overflow-x-hidden px-0 pb-6 -mr-[1px] scrollbar-thin lg:pb-8"
ref={navListElem}
>
{/* Render normal documents first */}
{normalDocs?.length > 0 &&
normalDocs.map((categoryData, index) => (
<div
key={`api-docs-${
categoryData.slug
? getUrl(categoryData.slug)
: categoryData.title
? categoryData.title
: categoryData.id
? categoryData.id
: `item-${index}`
}`}
>
<NavLevel
navListElem={navListElem}
categoryData={categoryData}
onNavigate={onNavigate}
endpoint_slug={categoryData.items?.map(
(item: any) => item.endpoint_slug
)}
/>
</div>
))}
{/* Render API endpoint groups */}
{processedApiGroups.map((categoryData, index) => (
<div key={`api-group-${categoryData.title}-${index}`}>
<NavLevel
navListElem={navListElem}
categoryData={categoryData}
onNavigate={onNavigate}
endpoint_slug={categoryData.items?.map(
(item: any) => item.endpoint_slug
)}
/>
</div>
))}
{/* Show message if no content */}
{normalDocs?.length === 0 && processedApiGroups.length === 0 && (
<div className="p-4 text-gray-500 text-sm">No content configured</div>
)}
</div>
);
};

View File

@@ -0,0 +1,38 @@
import { getUrl } from "@/utils/get-url";
import React from "react";
import { NavLevel } from "./nav-level";
import type { DocsNavProps } from "./types";
export const DocsNavigationItems: React.FC<
DocsNavProps & { __typename: string }
> = ({ navItems, __typename, onNavigate }) => {
const navListElem = React.useRef(null);
return (
<div
className="overflow-x-hidden px-0 pb-6 -mr-[1px] scrollbar-thin lg:pb-8"
ref={navListElem}
>
{navItems?.length > 0 &&
navItems?.map((categoryData, index) => (
<div
key={`mobile-${
categoryData.slug
? getUrl(categoryData.slug)
: categoryData.title
? categoryData.title
: categoryData.id
? categoryData.id
: `item-${index}`
}`}
>
<NavLevel
navListElem={navListElem}
categoryData={categoryData}
onNavigate={onNavigate}
/>
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,6 @@
export * from "./api-navigation-items";
export * from "./docs-navigation-items";
export * from "./nav-level";
export * from "./nav-title";
export * from "./types";
export * from "./utils";

View File

@@ -0,0 +1,245 @@
import { DynamicLink } from "@/components/ui/dynamic-link";
import settings from "@/content/settings/config.json";
import { matchActualTarget } from "@/utils/docs/urls";
import { getUrl } from "@/utils/get-url";
import { ChevronRightIcon } from "@heroicons/react/24/outline";
import { usePathname } from "next/navigation";
import React from "react";
import AnimateHeight from "react-animate-height";
import { titleCase } from "title-case";
import { PADDING_LEVELS, TRANSITION_DURATION } from "../constants";
import { NavTitle } from "./nav-title";
import { hasNestedSlug } from "./utils";
interface NavLevelProps {
navListElem?: React.RefObject<HTMLDivElement | null>;
categoryData: any;
level?: number;
onNavigate?: () => void;
endpoint_slug?: string | string[];
}
const getEndpointSlug = (endpoint_slug: string | string[] | undefined) => {
if (!endpoint_slug) return "";
if (typeof endpoint_slug === "string") {
return titleCase(endpoint_slug?.replace(/-/g, " "));
}
return endpoint_slug.length === 1
? titleCase(endpoint_slug[0]?.replace(/-/g, " "))
: "";
};
export const NavLevel: React.FC<NavLevelProps> = ({
navListElem,
categoryData,
level = 0,
onNavigate,
endpoint_slug,
}) => {
const navLevelElem = React.useRef<HTMLDivElement | null>(null);
const pathname = usePathname();
const path = pathname || "";
// If there is only one endpoint slug, use it as the default title
// This will be used only when endpoint title is not set
const defaultTitle = getEndpointSlug(endpoint_slug);
const slug = getUrl(categoryData.slug).replace(/\/$/, "");
const [expanded, setExpanded] = React.useState(
matchActualTarget(slug || getUrl(categoryData.href), path) ||
hasNestedSlug(categoryData.items, path) ||
level === 0
);
const selected =
path.split("#")[0] === slug || (slug === "/docs" && path === "/docs/");
const childSelected = hasNestedSlug(categoryData.items, path);
if (settings.autoApiTitles && categoryData.verb) {
categoryData.title = titleCase(categoryData.title);
}
const httpMethod = () => (
<span
className={`
inline-flex items-center justify-center px-0.5 py-1 my-1 rounded text-xs font-medium mr-1.5 flex-shrink-0 w-12
${
categoryData.verb === "get"
? pathname === slug
? "bg-green-100 text-green-800"
: "bg-green-100/75 group-hover:bg-green-100 text-green-800"
: ""
}
${
categoryData.verb === "post"
? pathname === slug
? "bg-blue-100 text-blue-800"
: "bg-blue-100/75 group-hover:bg-blue-100 text-blue-800"
: ""
}
${
categoryData.verb === "put"
? pathname === slug
? "bg-yellow-100 text-yellow-800"
: "bg-yellow-100/75 group-hover:bg-yellow-100 text-yellow-800"
: ""
}
${
categoryData.verb === "delete"
? pathname === slug
? "bg-red-100 text-red-800"
: "bg-red-100/75 group-hover:bg-red-100 text-red-800"
: ""
}
${
categoryData.verb === "patch"
? pathname === slug
? "bg-purple-100 text-purple-800"
: "bg-purple-100/75 group-hover:bg-purple-100 text-purple-800"
: ""
}
${
!["get", "post", "put", "delete", "patch"].includes(categoryData.verb)
? pathname === slug
? "bg-gray-100 text-gray-800"
: "bg-gray-100/75 group-hover:bg-gray-100 text-gray-800"
: ""
}
`}
>
{categoryData.verb === "delete" ? "DEL" : categoryData.verb.toUpperCase()}
</span>
);
React.useEffect(() => {
if (
navListElem &&
navLevelElem.current &&
navListElem.current &&
selected
) {
const scrollOffset = navListElem.current?.scrollTop || 0;
const navListOffset =
navListElem.current?.getBoundingClientRect()?.top || 0;
const navListHeight = navListElem.current?.offsetHeight || 0;
const navItemOffset = navLevelElem.current?.getBoundingClientRect()?.top;
const elementOutOfView =
navItemOffset - navListOffset > navListHeight + scrollOffset;
if (elementOutOfView && navLevelElem.current) {
navLevelElem.current.scrollIntoView({
behavior: "auto",
block: "center",
inline: "nearest",
});
}
}
}, [navListElem, selected]);
return (
<>
<div
ref={navLevelElem}
className={`relative flex w-full last:pb-[0.375rem] ${
categoryData.status
? "after:content-[attr(data-status)] after:text-xs after:font-bold after:bg-[#f9ebe6] after:border after:border-[#edcdc4] after:w-fit after:px-[5px] after:py-[2px] after:rounded-[5px] after:tracking-[0.25px] after:text-[#ec4815] after:mr-[5px] after:ml-[5px] after:leading-none after:align-middle after:h-fit after:self-center"
: ""
}`}
data-status={categoryData.status?.toLowerCase()}
style={{
paddingLeft:
level === 0
? PADDING_LEVELS.level0.left
: PADDING_LEVELS.default.left,
paddingRight:
level === 0
? PADDING_LEVELS.level0.right
: PADDING_LEVELS.default.right,
paddingTop:
level === 0
? PADDING_LEVELS.level0.top
: PADDING_LEVELS.default.top,
paddingBottom:
level === 0
? PADDING_LEVELS.level0.bottom
: PADDING_LEVELS.default.bottom,
}}
>
{categoryData.slug ? (
<DynamicLink
href={getUrl(categoryData.slug)}
passHref
onClick={onNavigate}
isFullWidth={true}
>
<NavTitle level={level} selected={selected && !childSelected}>
<span className="flex items-center justify-between font-body w-full">
{categoryData.verb && httpMethod()}
<span
className="flex-1 min-w-0"
style={{ overflowWrap: "anywhere" }}
>
{categoryData.slug.title ||
categoryData.title ||
defaultTitle}
</span>
<ChevronRightIcon className="ml-2 flex-shrink-0 opacity-0 w-5 h-auto" />
</span>
</NavTitle>
</DynamicLink>
) : (
<NavTitle
level={level}
selected={selected && !childSelected}
childSelected={childSelected}
onClick={() => {
setExpanded(!expanded);
}}
>
<span className="flex items-center justify-start font-body w-full">
<span
className="flex-1 min-w-0"
style={{ overflowWrap: "anywhere" }}
>
{categoryData.title}
</span>
{categoryData.items && (
<ChevronRightIcon
className={`ml-2 flex-shrink-0 w-5 h-auto transition-[300ms] ease-out group-hover:rotate-90 ${
level < 1
? "text-neutral-text font-bold"
: "text-neutral-text-secondary group-hover:text-neutral-text"
} ${expanded ? "rotate-90" : ""}`}
/>
)}
</span>
</NavTitle>
)}
</div>
{categoryData.items && (
<AnimateHeight
duration={TRANSITION_DURATION}
height={expanded ? "auto" : 0}
>
<div className="relative block">
{(categoryData.items || []).map((item: any, index: number) => (
<div
key={`child-container-${
item.slug ? getUrl(item.slug) + level : item.title + level
}`}
>
<NavLevel
navListElem={navListElem}
level={level + 1}
categoryData={item}
onNavigate={onNavigate}
endpoint_slug={endpoint_slug?.[index]}
/>
</div>
))}
</div>
</AnimateHeight>
)}
</>
);
};

View File

@@ -0,0 +1,51 @@
// biome-ignore lint/style/useImportType: <explanation>
import React from "react";
import { FONT_SIZES, FONT_WEIGHTS } from "../constants";
import type { NavTitleProps } from "./types";
export const NavTitle: React.FC<NavTitleProps> = ({
children,
level = 3,
selected,
childSelected,
...props
}: NavTitleProps) => {
const baseStyles =
"group flex cursor-pointer items-center py-0.5 leading-tight transition duration-150 ease-out hover:opacity-100 w-full";
const headerLevelClasses = {
0: `${FONT_WEIGHTS.light} text-neutral-text ${FONT_SIZES.xl} pt-4 opacity-100`,
1: {
default: `${FONT_SIZES.base} ${FONT_WEIGHTS.light} pl-3 pt-1 text-neutral-text-secondary hover:text-neutral-text `,
selected: `${FONT_SIZES.base} ${FONT_WEIGHTS.semibold} pl-3 pt-1 text-brand-primary `,
childSelected: `${FONT_SIZES.base} ${FONT_WEIGHTS.normal} pl-3 pt-1 text-neutral-text`,
},
2: {
default: `${FONT_SIZES.small} ${FONT_WEIGHTS.light} pl-6 opacity-80 pt-0.5 text-neutral-text-secondary hover:text-neutral-text `,
selected: `${FONT_SIZES.small} ${FONT_WEIGHTS.semibold} pl-6 pt-0.5 text-brand-primary `,
childSelected: `${FONT_SIZES.small} ${FONT_WEIGHTS.normal} pl-6 pt-1 text-neutral-text`,
},
3: {
default: `${FONT_SIZES.small} ${FONT_WEIGHTS.light} pl-9 opacity-80 pt-0.5 text-neutral-text rounded-lg`,
selected: `${FONT_SIZES.small} ${FONT_WEIGHTS.semibold} pl-9 pt-0.5 text-brand-primary`,
childSelected: `${FONT_SIZES.small} ${FONT_WEIGHTS.normal} pl-9 pt-1 text-neutral-text`,
},
};
const headerLevel = level > 3 ? 3 : level;
const selectedClass = selected
? "selected"
: childSelected
? "childSelected"
: "default";
const classes =
level < 1
? headerLevelClasses[headerLevel]
: headerLevelClasses[headerLevel][selectedClass];
return (
<div className={`${baseStyles} ${classes}`} {...props}>
{children}
</div>
);
};

View File

@@ -0,0 +1,32 @@
export interface ApiEndpoint {
method: string;
path: string;
summary: string;
operationId?: string;
schema: string;
}
export interface ApiGroup {
tag: string;
endpoints: ApiEndpoint[];
}
export interface ApiGroupData {
schema: string;
tag: string;
endpoints: ApiEndpoint[] | string[];
}
export interface NavTitleProps {
children: React.ReactNode;
level?: number;
selected?: boolean;
childSelected?: boolean;
onClick?: () => void;
[key: string]: any;
}
export interface DocsNavProps {
navItems: any[];
onNavigate?: () => void;
}

View File

@@ -0,0 +1,127 @@
import { matchActualTarget } from "@/utils/docs/urls";
import { getUrl } from "@/utils/get-url";
import type { ApiEndpoint, ApiGroupData } from "./types";
export const getEndpointSlug = (method: string, path: string) => {
// Match the exact filename generation logic from our API endpoint generator
const pathSafe = path
.replace(/^\//, "") // Remove leading slash
.replace(/\//g, "-") // Replace slashes with dashes
.replace(/[{}]/g, "") // Remove curly braces
.replace(/[^\w-]/g, "") // Remove any non-word characters except dashes
.toLowerCase();
return `${method.toLowerCase()}-${pathSafe}`;
};
export const getTagSlug = (tag: string) => {
// Match the exact tag sanitization logic from our API endpoint generator
return tag
.replace(/[^\w\s-]/g, "") // Remove special characters except spaces and dashes
.replace(/\s+/g, "-") // Replace spaces with dashes
.toLowerCase();
};
export const hasNestedSlug = (navItems: any[], slug: string) => {
for (const item of Array.isArray(navItems) ? navItems : []) {
if (matchActualTarget(getUrl(item.slug || item.href), slug)) {
return true;
}
if (item.items) {
if (hasNestedSlug(item.items, slug)) {
return true;
}
}
}
return false;
};
export const hasMatchingApiEndpoint = (items: any[], path: string) => {
return items?.some((item: any) => {
if (!item.apiGroup) return false;
try {
const apiGroupData: ApiGroupData = JSON.parse(item.apiGroup);
const { tag, endpoints } = apiGroupData;
if (!tag || !endpoints) return false;
return endpoints.some((endpoint: any) => {
const method =
endpoint.method ||
(typeof endpoint === "string" ? endpoint.split(":")[0] : "GET");
const endpointPath =
endpoint.path ||
(typeof endpoint === "string" ? endpoint.split(":")[1] : "");
return (
path ===
`/docs/api-documentation/${getTagSlug(tag)}/${getEndpointSlug(
method,
endpointPath
)}`
);
});
} catch (error) {
return false;
}
});
};
export const processApiGroups = (
navItems: any[]
): { normalDocs: any[]; apiGroups: Record<string, ApiEndpoint[]> } => {
if (!navItems?.length) return { normalDocs: [], apiGroups: {} };
const normalDocs: any[] = [];
const apiGroups: Record<string, ApiEndpoint[]> = {};
for (const item of navItems) {
if (item.apiGroup) {
try {
const apiGroupData: ApiGroupData = JSON.parse(item.apiGroup);
const { tag, endpoints } = apiGroupData;
if (tag && endpoints) {
if (!apiGroups[tag]) {
apiGroups[tag] = [];
}
if (Array.isArray(endpoints) && endpoints.length > 0) {
if (typeof endpoints[0] === "object" && "method" in endpoints[0]) {
// New format: array of objects
apiGroups[tag].push(
...(endpoints as ApiEndpoint[]).map((endpoint) => ({
method: endpoint.method || "GET",
path: endpoint.path || "",
summary: endpoint.summary || "",
operationId: endpoint.operationId,
schema: apiGroupData.schema || "",
}))
);
} else {
// Legacy format: array of strings
apiGroups[tag].push(
...(endpoints as string[]).map((endpointId) => {
const [method, path] = endpointId.split(":");
return {
method: method || "GET",
path: path || "",
summary: `${method} ${path}`,
operationId: endpointId,
schema: apiGroupData.schema || "",
};
})
);
}
}
}
} catch (error) {
// Continue processing other items
}
} else {
normalDocs.push(item);
}
}
return { normalDocs, apiGroups };
};

View File

@@ -0,0 +1,30 @@
"use client";
import {
ApiNavigationItems,
DocsNavigationItems,
} from "./navigation-items/index";
export const NavigationSideBar = ({
tableOfContents,
}: {
tableOfContents: any;
}) => {
const typename = tableOfContents.__typename;
return (
<div className="overflow-y-auto overflow-x-hidden">
{typename?.includes("DocsTab") ? (
<DocsNavigationItems
navItems={tableOfContents.items}
__typename={tableOfContents.__typename}
/>
) : (
<ApiNavigationItems
navItems={tableOfContents.items}
__typename={tableOfContents.__typename}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,150 @@
import { Bars3Icon } from "@heroicons/react/24/outline";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { MdArrowDropDown, MdClose } from "react-icons/md";
import { findTabWithPath } from "../docs/layout/utils";
import {
ApiNavigationItems,
DocsNavigationItems,
} from "./navigation-items/index";
export const NavigationToggle = ({ onToggle }: { onToggle: () => void }) => {
return (
<Bars3Icon
onClick={onToggle}
className="size-9 flex items-center justify-center mx-5 md:mr-6 md:ml-0 text-brand-secondary-contrast lg:hidden cursor-pointer"
/>
);
};
export const NavigationDropdownContent = ({
tocData,
onClose,
}: {
tocData: any;
onClose: () => void;
}) => {
const pathname = usePathname();
const path = pathname || "";
const [selectedValue, setSelectedValue] = useState(
findTabWithPath(tocData, path)
);
const dropdownRef = useRef(null);
const [isOpen, setIsOpen] = useState(false);
const options = tocData?.map((option: any) => ({
value: option.label,
label: option.label,
content: option.content.items,
__typename: option.__typename,
}));
// Update selected value when pathname changes
useEffect(() => {
const newSelectedValue = findTabWithPath(tocData, path);
setSelectedValue(newSelectedValue);
}, [path, tocData]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!(dropdownRef.current as any).contains(event.target)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
return (
<>
<div
onClick={onClose}
className="fixed inset-0 bg-[rgba(0,0,0,0.4)] z-10 lg:hidden"
/>
<div className="max-w-96 fixed top-0 right-0 z-20 h-screen w-[75%] overflow-y-auto bg-neutral-background border-l border-neutral-border-subtle p-6 shadow-xl lg:hidden">
<div className="flex justify-end mb-4">
<MdClose
onClick={onClose}
className="size-11 text-brand-secondary-contrast cursor-pointer"
/>
</div>
<div className="relative w-full mb-4" ref={dropdownRef}>
<button
type="button"
className="w-full p-2 px-4 rounded-lg bg-neutral-background-primary border border-neutral-border-subtle flex items-center justify-between focus:outline-none"
onClick={() => setIsOpen(!isOpen)}
>
<span>
{options.find((opt) => opt.value === selectedValue)?.label}
</span>
<MdArrowDropDown
className={`size-6 text-brand-secondary-dark-dark transition-transform duration-200 ${
isOpen ? "rotate-180" : ""
}`}
/>
</button>
{isOpen && (
<div className="absolute z-30 w-full mt-1 bg-neutral-background border border-neutral-border-subtle rounded-lg shadow-lg">
{options.map((option) => (
<button
type="button"
key={option.value}
className={`w-full p-2 px-4 text-left first:rounded-t-lg last:rounded-b-lg ${
selectedValue === option.value
? "bg-neutral-background-secondary text-brand-secondary"
: ""
}`}
onClick={() => {
setSelectedValue(option.value);
setIsOpen(false);
}}
>
{option.label}
</button>
))}
</div>
)}
</div>
<div className="h-[calc(100vh-250px)] overflow-y-auto px-4 pb-4">
{options.find((opt) => opt.value === selectedValue)?.__typename ===
"NavigationBarTabsApiTab" ? (
<ApiNavigationItems
navItems={
options.find((opt) => opt.value === selectedValue)?.content ||
[]
}
__typename={
options.find((opt) => opt.value === selectedValue)
?.__typename || ""
}
onNavigate={onClose}
/>
) : (
<DocsNavigationItems
navItems={
options.find((opt) => opt.value === selectedValue)?.content ||
[]
}
__typename={
options.find((opt) => opt.value === selectedValue)
?.__typename || ""
}
onNavigate={onClose}
/>
)}
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,11 @@
export interface DocsNavProps {
navItems: any;
}
export interface NavTitleProps {
level: number;
selected: boolean;
childSelected?: boolean;
children: React.ReactNode | React.ReactNode[];
onClick?: () => void;
}

View File

@@ -0,0 +1,61 @@
"use client";
import { createContext, useContext } from "react";
export interface GitHubCommit {
sha: string;
commit: {
author: {
name: string;
email: string;
date: string;
};
committer: {
name: string;
email: string;
date: string;
};
message: string;
};
author: {
login: string;
avatar_url: string;
} | null;
committer: {
login: string;
avatar_url: string;
} | null;
}
export interface GitHubMetadataResponse {
latestCommit: GitHubCommit;
firstCommit: GitHubCommit | null;
historyUrl: string;
}
interface GitHubMetadataContextType {
data: GitHubMetadataResponse | null;
}
const GitHubMetadataContext = createContext<GitHubMetadataContextType | null>(
null
);
export function GitHubMetadataProvider({
children,
data,
}: {
children: React.ReactNode;
data: GitHubMetadataResponse | null;
}) {
return (
<GitHubMetadataContext.Provider value={{ data }}>
{children}
</GitHubMetadataContext.Provider>
);
}
export function useGitHubMetadata() {
const context = useContext(GitHubMetadataContext);
return context;
}

View File

@@ -0,0 +1,61 @@
"use client";
import { useGitHubMetadata } from "@/src/components/page-metadata/github-metadata-context";
import { formatDate } from "date-fns";
import Link from "next/link";
import { FaHistory } from "react-icons/fa";
import { getRelativeTime } from "./timeUtils";
import type { GitHubMetadataProps } from "./type";
export default function GitHubMetadata({
className = "",
}: Omit<GitHubMetadataProps, "path">) {
const { data } = useGitHubMetadata();
if (!data) {
return (
<div className={`text-slate-400 text-sm ${className}`}>
Unable to load last updated info
</div>
);
}
const { latestCommit, firstCommit, historyUrl } = data;
const lastUpdatedDate = latestCommit.commit.author.date;
const lastUpdateInRelativeTime = getRelativeTime(lastUpdatedDate);
const lastUpdateInAbsoluteTime = formatDate(lastUpdatedDate, "dd MMM yyyy");
const createdDate = firstCommit?.commit.author.date;
const createdTime = createdDate
? formatDate(createdDate, "d MMM yyyy")
: null;
const tooltipContent = createdTime
? `Created ${createdTime}\nLast updated ${lastUpdateInAbsoluteTime}`
: `Last updated ${lastUpdateInAbsoluteTime}`;
return (
<div className={`text-slate-500 text-sm ${className}`}>
<div className="flex sm:flex-row flex-col sm:items-center gap-2">
<span>
Last updated by{" "}
<span className="font-bold text-black">
{latestCommit.commit.author.name}
</span>
{` ${lastUpdateInRelativeTime}.`}
</span>
<div className="relative group">
<Link
href={historyUrl}
target="_blank"
title={tooltipContent}
rel="noopener noreferrer"
className="text-black hover:text-orange-600 underline flex flex-row items-center gap-1.5"
>
See history
<FaHistory className="w-3 h-3" />
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { default as GitHubMetadata } from "./github-metadata";
export { getPreciseRelativeTime, getRelativeTime } from "./timeUtils";

View File

@@ -0,0 +1,94 @@
/**
* Converts a date string to a human-readable relative time format
* @param dateString - ISO date string
* @returns Relative time string (e.g., "2 hours ago", "3 days ago", "1 month ago")
*/
export function getRelativeTime(dateString: string): string {
const now = new Date();
const date = new Date(dateString);
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
// If the date is in the future, return "just now"
if (diffInSeconds < 0) {
return "just now";
}
// Less than 1 minute
if (diffInSeconds < 60) {
return "just now";
}
// Less than 1 hour
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) {
return diffInMinutes === 1
? "1 minute ago"
: `${diffInMinutes} minutes ago`;
}
// Less than 1 day
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) {
return diffInHours === 1 ? "1 hour ago" : `${diffInHours} hours ago`;
}
// Less than 1 month (30 days)
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 30) {
return diffInDays === 1 ? "1 day ago" : `${diffInDays} days ago`;
}
// Less than 1 year (365 days)
const diffInMonths = Math.floor(diffInDays / 30);
if (diffInMonths < 12) {
return diffInMonths === 1 ? "1 month ago" : `${diffInMonths} months ago`;
}
// 1 year or more
const diffInYears = Math.floor(diffInDays / 365);
return diffInYears === 1 ? "1 year ago" : `${diffInYears} years ago`;
}
/**
* Gets a more precise relative time for recent updates (within 7 days)
* @param dateString - ISO date string
* @returns More precise relative time string
*/
export function getPreciseRelativeTime(dateString: string): string {
const now = new Date();
const date = new Date(dateString);
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
// If the date is in the future, return "just now"
if (diffInSeconds < 0) {
return "just now";
}
// Less than 1 minute
if (diffInSeconds < 60) {
return "just now";
}
// Less than 1 hour
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) {
return diffInMinutes === 1
? "1 minute ago"
: `${diffInMinutes} minutes ago`;
}
// Less than 1 day
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) {
return diffInHours === 1 ? "1 hour ago" : `${diffInHours} hours ago`;
}
// Less than 1 week
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) {
return diffInDays === 1 ? "1 day ago" : `${diffInDays} days ago`;
}
// For older dates, fall back to the regular relative time
return getRelativeTime(dateString);
}

View File

@@ -0,0 +1,41 @@
export interface GitHubCommit {
sha: string;
commit: {
author: {
name: string;
email: string;
date: string;
};
committer: {
name: string;
email: string;
date: string;
};
message: string;
};
author: {
login: string;
avatar_url: string;
} | null;
committer: {
login: string;
avatar_url: string;
} | null;
}
export interface GitHubMetadataProps {
/** GitHub repository owner (e.g., 'tinacms') */
owner?: string;
/** GitHub repository name (e.g., 'tina.io') */
repo?: string;
/** Optional path to a specific file in the repository */
path?: string;
/** Additional CSS classes to apply to the component */
className?: string;
}
export interface GitHubMetadataResponse {
latestCommit: GitHubCommit;
firstCommit: GitHubCommit | null;
historyUrl: string;
}

View File

@@ -0,0 +1,81 @@
import Link from "next/link";
interface SearchResult {
url: string;
title: string;
excerpt: string;
}
interface SearchResultsProps {
results: SearchResult[];
isLoading: boolean;
searchTerm: string;
}
const searchResultsContainer =
"absolute mt-2 p-4 z-10 py-2 max-h-[45vh] md:w-11/12 w-full mx-auto rounded-lg shadow-lg md:ml-2 left translate-x-1 overflow-y-auto bg-neutral-background";
export function SearchResults({
results,
isLoading,
searchTerm,
}: SearchResultsProps) {
if (isLoading) {
return (
<div
className={searchResultsContainer}
data-testid="search-results-container"
>
<h4 className="text-brand-primary font-bold my-2">
Mustering all the Llamas...
</h4>
</div>
);
}
if (results.length > 0) {
return (
<div
className={searchResultsContainer}
data-testid="search-results-container"
>
{results.map((result, index) => (
<Link
key={index}
href={result.url}
className="block p-2 border-b-1 border-b-gray-200 last:border-b-0 group"
>
<h3 className="font-medium text-brand-primary group-hover:text-orange-400">
{result.title}
</h3>
<p
className="mt-1 text-sm text-neutral-text"
// biome-ignore lint/security/noDangerouslySetInnerHtml: For Highlighting the search term, it is important to use dangerouslySetInnerHTML
dangerouslySetInnerHTML={{
__html: result.excerpt || "",
}}
/>
</Link>
))}
</div>
);
}
if (searchTerm.length > 0) {
return (
<div
className={searchResultsContainer}
data-testid="search-results-container"
>
<div
className="py-2 px-4 text-md font-inter font-semibold text-gray-500 text-bold"
data-testid="no-results-message"
>
No Llamas Found...
</div>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,146 @@
"use client";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { useEffect, useRef, useState } from "react";
import { SearchResults } from "./search-results";
const isDev = process.env.NODE_ENV === "development";
// In development, the pagefind-entry.json is served from the root of the project.
// In production, it is served from the _next/static/pagefind directory.
const pagefindPath = isDev
? "/pagefind"
: `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/_next/static/pagefind`;
export function Search({ className }: { className?: string }) {
const [searchTerm, setSearchTerm] = useState("");
const [results, setResults] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const searchContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
searchContainerRef.current &&
!searchContainerRef.current.contains(event.target as Node)
) {
setResults([]);
setSearchTerm("");
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const handleSearch = async (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchTerm(value);
setError(null);
if (!value.trim()) {
setResults([]);
setSearchTerm("");
return;
}
setIsLoading(true);
try {
if (typeof window !== "undefined") {
let pagefindModule: any;
try {
// Using eval to import pagefind.js is a workaround since the script isn't available during the build process.
// This also improves performance by loading the script only when needed, reducing initial page load time.
// A direct import would require committing the file with the codebase, which would change frequently
// with every content update.
pagefindModule = await (window as any).eval(
`import("${pagefindPath}/pagefind.js")`
);
} catch (importError) {
setError(
"Unable to load search functionality. For more information, please check this README: https://github.com/tinacms/tina-docs?tab=readme-ov-file#search-functionality and refresh the page."
);
return;
}
const search = await pagefindModule.search(value);
const searchResults = await Promise.all(
search.results.map(async (result: any) => {
const data = await result.data();
const searchTerms = value.toLowerCase().match(/\w+/g) || [];
const textToSearch = `${data.meta.title || ""} ${
data.excerpt
}`.toLowerCase();
const words = textToSearch.match(/\w+/g) || [];
const matchFound = searchTerms.every((term) =>
words.some((word: string) => word.includes(term))
);
if (!matchFound) return null;
return {
url: data.raw_url
.replace(/^\/server\/app/, "")
.replace(/\.html$/, "")
.replace(/\/+/g, "/")
.trim(),
title: data.meta.title || "Untitled",
excerpt: data.excerpt,
};
})
);
const filteredResults = searchResults.filter(Boolean);
setResults(filteredResults);
}
} catch (error) {
setError("An error occurred while searching. Please try again.");
setResults([]);
} finally {
setIsLoading(false);
}
};
return (
<div
className="relative w-full md:max-w-lg lg:my-4 lg:mb-4"
ref={searchContainerRef}
>
<div className={`relative md:mr-4 ${className || ""}`}>
<input
type="text"
value={searchTerm}
className={`w-full text-neutral-text p-1 lg:p-2 lg:pl-6 pl-6 rounded-full bg-neutral-background-secondary shadow-lg border border-neutral-border/50 dark:border-neutral-border-subtle/50 focus:outline-none focus:ring-1 focus:ring-[#0574e4]/50 focus:border-[#0574e4]/50 transition-all ${
error !== null ? "opacity-50 cursor-not-allowed" : ""
}`}
placeholder="Search..."
onChange={handleSearch}
/>
<MagnifyingGlassIcon className="absolute right-3 top-1/2 transform -translate-y-1/2 text-brand-primary h-5 w-5" />
</div>
{error && (
<div className="md:mt-2 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm w-11/12 mx-auto absolute left-3 z-10">
{error}
</div>
)}
{!error && (
<SearchResults
results={results}
isLoading={isLoading}
searchTerm={searchTerm}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,69 @@
import {
Highlight,
type Language,
type RenderProps,
Prism as rootPrism,
themes,
} from "prism-react-renderer";
import React from "react";
(typeof global !== "undefined" ? global : window).Prism = rootPrism;
require("prismjs/components/prism-bash");
require("prismjs/components/prism-diff");
require("prismjs/components/prism-css");
require("prismjs/components/prism-json");
export const Prism = (props: {
value: string;
lang?: Language;
theme?: keyof typeof themes;
}) => {
const language = props.lang || "bash";
const codeBlock = ({
className,
style,
tokens,
getLineProps,
getTokenProps,
}: RenderProps) => (
<pre
className={`${className} p-3`}
style={{
...style,
width: "100%",
border: "none",
marginBottom: 0,
borderRadius: "12px",
}}
>
{tokens.map((line, i) => {
const { key: lineKey, ...lineProps } = getLineProps({
line,
key: i,
});
return (
<div key={`prism-line-${lineKey}`} {...lineProps}>
{line.map((token, key) => {
const { key: tokenKey, ...tokenProps } = getTokenProps({
token,
key,
});
return <span key={`prism-token-${tokenKey}`} {...tokenProps} />;
})}
</div>
);
})}
</pre>
);
return (
<Highlight
theme={themes[props.theme || "github"]}
code={props.value}
language={language}
children={codeBlock}
/>
);
};

View File

@@ -0,0 +1,221 @@
import { MinusIcon, PlusIcon } from "@heroicons/react/24/outline";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { tinaField } from "tinacms/dist/react";
import { TinaMarkdown } from "tinacms/dist/rich-text";
import { ImageOverlayWrapper } from "../../ui/image-overlay-wrapper";
import MarkdownComponentMapping from "../markdown-component-mapping";
interface AccordionProps {
docText: string;
image: string;
heading?: string;
fullWidth?: boolean;
}
const Accordion = (props) => {
const { docText, image, heading, fullWidth = true }: AccordionProps = props;
const [isExpanded, setIsExpanded] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const toggleExpand = () => {
setIsExpanded(!isExpanded);
};
return (
<div className="flex flex-col justify-center items-center">
{/* Header */}
<div
className={`mb-5 max-w-full overflow-hidden rounded-lg bg-neutral-background shadow-md transition-[width] duration-700 ease-in-out border border-neutral-border ${
fullWidth ? "w-full" : "w-3/4"
}`}
data-tina-field={tinaField(props, "heading")}
>
<div
className="flex cursor-pointer items-center justify-between px-6 py-6"
onClick={toggleExpand}
>
<h4 className="text-neutral-text text-base font-heading mt-0.5">
{heading || "Click to expand"}
</h4>
<div>
{isExpanded ? (
<MinusIcon className="size-5 text-neutral-text" />
) : (
<PlusIcon className="size-5 text-neutral-text" />
)}
</div>
</div>
{/* Expandable content */}
<div
className={`grid gap-4 transition-all duration-700 ease-in-out ${
isExpanded
? "max-h-[2000px] opacity-100"
: "max-h-0 overflow-hidden opacity-0"
} ${image ? "sm:grid-cols-2" : ""}`}
ref={contentRef}
data-tina-field={tinaField(props, "docText")}
>
<div className="p-4">
<TinaMarkdown
content={docText as any}
components={MarkdownComponentMapping}
/>
</div>
{image && (
<div className="p-4" data-tina-field={tinaField(props, "image")}>
<ImageOverlayWrapper src={image} alt="image" caption={heading}>
<Image
src={
image.startsWith("http")
? image
: `${process.env.NEXT_PUBLIC_BASE_PATH || ""}${image}`
}
alt="image"
className="rounded-lg"
width={500}
height={500}
/>
</ImageOverlayWrapper>
</div>
)}
</div>
</div>
</div>
);
};
export default Accordion;
interface AccordionBlockProps {
fullWidth?: boolean;
accordionItems: {
docText: string;
image: string;
heading?: string;
fullWidth?: boolean;
}[];
}
export const AccordionBlock = (props) => {
const { accordionItems, fullWidth = false }: AccordionBlockProps = props;
const [isExpanded, setIsExpanded] = useState<boolean[]>(
accordionItems?.map(() => false) || []
);
const contentRefs = useRef<(HTMLDivElement | null)[]>([]);
const [accordionLength, setAccordionLength] = useState(
accordionItems?.length || 0
);
useEffect(() => {
setAccordionLength(accordionItems?.length || 0);
setIsExpanded((prev) => {
// Keep existing expanded states for items that still exist
// and initialize new ones as false
const newExpanded =
accordionItems?.map((_, i) => (i < prev.length ? prev[i] : false)) ||
[];
return newExpanded;
});
}, [accordionItems]);
const toggleExpand = (index: number) => {
setIsExpanded((prev) => {
const newIsExpanded = [...prev];
newIsExpanded[index] = !newIsExpanded[index];
return newIsExpanded;
});
};
// If accordionItems is undefined or empty, return empty div or loading state
if (!accordionItems || accordionItems.length === 0) {
return (
<div className="flex flex-col justify-center items-center rounded-lg bg-white/40 shadow-lg mb-5 p-4 border border-neutral-border">
No accordion items
</div>
);
}
return (
<div
className={`mx-auto flex flex-col justify-center items-center rounded-lg bg-neutral-background shadow-md mb-5 border border-neutral-border ${
fullWidth ? "w-full" : "w-3/4"
}`}
>
{accordionItems.map((item, index) => (
<div key={index} className="w-full">
<div
className="flex cursor-pointer items-center justify-between px-6 py-6"
onClick={() => toggleExpand(index)}
data-tina-field={tinaField(props.accordionItems[index], "heading")}
>
<h4 className="text-neutral-text text-base font-heading mt-0.5">
{item.heading || "Click to expand"}
</h4>
<div>
{isExpanded[index] ? (
<MinusIcon className="size-5 text-neutral-text" />
) : (
<PlusIcon className="size-5 text-neutral-text" />
)}
</div>
</div>
<div
className={`grid gap-4 transition-all duration-700 ease-in-out ${
isExpanded[index]
? "max-h-[2000px] opacity-100"
: "max-h-0 overflow-hidden opacity-0"
} ${item.image ? "sm:grid-cols-2" : ""}`}
ref={(el: HTMLDivElement | null) => {
contentRefs.current[index] = el;
}}
data-tina-field={tinaField(props.accordionItems[index], "docText")}
>
<div
className="px-4"
data-tina-field={tinaField(
props.accordionItems[index],
"docText"
)}
>
<TinaMarkdown
content={item.docText as any}
components={MarkdownComponentMapping}
/>
</div>
{item.image && (
<div
className="p-4"
data-tina-field={tinaField(
props.accordionItems[index],
"image"
)}
>
<ImageOverlayWrapper
src={item.image}
alt="image"
caption={item?.heading}
>
<Image
src={
item.image.startsWith("http")
? item.image
: `${process.env.NEXT_PUBLIC_BASE_PATH || ""}${item.image}`
}
alt="image"
className="rounded-lg"
width={500}
height={500}
/>
</ImageOverlayWrapper>
</div>
)}
</div>
{index < accordionLength - 1 && (
<hr className="w-full h-0.5 text-neutral-border/50" />
)}
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,139 @@
import { ErrorsSection } from "./error-section";
import { RequestBodySection } from "./request-body-section";
import { ResponseBodySection } from "./response-body-section";
import type {
Endpoint,
ExpandedResponsesState,
ResponseViewState,
} from "./types";
export const EndpointSection = (
endpoint: Endpoint,
requestBodyView: "schema" | "example",
setRequestBodyView: React.Dispatch<
React.SetStateAction<"schema" | "example">
>,
expandedResponses: ExpandedResponsesState,
setExpandedResponses: React.Dispatch<
React.SetStateAction<ExpandedResponsesState>
>,
responseView: ResponseViewState,
setResponseView: React.Dispatch<React.SetStateAction<ResponseViewState>>,
schemaDefinitions: any
) => {
return (
<div
key={endpoint.path + endpoint.method}
className="mb-12 bg-neutral-background-secondary border border-neutral-border p-4 rounded-lg shadow-md"
>
<Header endpoint={endpoint} />
<>
{/* Parameters section - only show if there are non-body parameters */}
{endpoint.parameters && endpoint.parameters.length > 0 && (
<ParametersSection parameters={endpoint.parameters} />
)}
{/* Request Body section */}
{endpoint.requestBody && (
<RequestBodySection
requestBody={endpoint.requestBody}
requestBodyView={requestBodyView}
setRequestBodyView={setRequestBodyView}
schemaDefinitions={schemaDefinitions}
/>
)}
{/* Responses section */}
<ResponseBodySection
responses={endpoint.responses}
endpoint={endpoint}
expandedResponses={expandedResponses}
setExpandedResponses={setExpandedResponses}
responseView={responseView}
setResponseView={setResponseView}
schemaDefinitions={schemaDefinitions}
/>
{/* Errors section for 4XX and 5XX */}
{Object.entries(endpoint.responses || {}).some(
([code]) => code.startsWith("4") || code.startsWith("5")
) && (
<ErrorsSection
responses={endpoint.responses}
endpoint={endpoint}
expandedResponses={expandedResponses}
setExpandedResponses={setExpandedResponses}
responseView={responseView}
setResponseView={setResponseView}
schemaDefinitions={schemaDefinitions}
/>
)}
</>
</div>
);
};
const Header = ({ endpoint }: { endpoint: Endpoint }) => {
return (
<div className="flex flex-col gap-2 pb-6">
<div className="flex items-center gap-4">
<span
className={`px-3 py-1 rounded-md text-sm shadow-sm font-bold ${
endpoint.method === "GET"
? "bg-[#B4EFD9] text-green-800"
: endpoint.method === "POST"
? "bg-[#B4DBFF] text-blue-800"
: endpoint.method === "PUT"
? "bg-[#FEF3C7] text-yellow-800"
: endpoint.method === "DELETE"
? "bg-[#FEE2E2] text-red-800"
: "bg-gray-50"
}`}
>
{endpoint.method}
</span>
<span className="font-mono text-neutral-text text-sm brand-glass-gradient shadow-sm rounded-lg px-2 py-1 ">
{endpoint.path}
</span>
</div>
<span className="text-neutral-text-secondary text-sm">
{endpoint?.description}
</span>
</div>
);
};
const ParametersSection = ({ parameters }: { parameters: any[] }) => {
if (!parameters || parameters.length === 0) return null;
return (
<div className="mb-8">
<h4 className="text-xl text-neutral-text mb-2">Path Parameters</h4>
<div className="space-y-4 pl-3">
{parameters.map((param: any, index: number) => (
<div key={index}>
<div className="flex items-center mb-2">
<span className="text-neutral-text mr-2">{param.name}</span>
<span className="mr-2 px-2 py-0.5 brand-glass-gradient shadow-sm font-mono text-xs text-neutral-text rounded-md">
{param.in}
</span>
<span className="mr-2 px-2 py-0.5 brand-glass-gradient shadow-sm font-mono text-xs text-neutral-text rounded-md">
{param.type}
</span>
{param.required && (
<span className="px-2 py-0.5 text-xs rounded-full bg-brand-primary-light text-black dark:text-white font-tuner">
required
</span>
)}
</div>
{param.description && (
<p className="text-neutral-text-secondary mb-3 text-sm">
{param.description}
</p>
)}
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,172 @@
import { SchemaContext } from ".";
import { CodeBlock } from "../../standard-elements/code-block/code-block";
import { RequestBodyDropdown } from "./request-body-section";
import { SchemaType } from "./scheme-type";
import type {
Endpoint,
ExpandedResponsesState,
ResponseViewState,
} from "./types";
import { generateExample } from "./utils";
export const ErrorsSection = ({
responses,
endpoint,
expandedResponses,
setExpandedResponses,
responseView,
setResponseView,
schemaDefinitions,
}: {
responses: any;
endpoint: Endpoint;
expandedResponses: ExpandedResponsesState;
setExpandedResponses: (state: ExpandedResponsesState) => void;
responseView: ResponseViewState;
setResponseView: (
updater: (prev: ResponseViewState) => ResponseViewState
) => void;
schemaDefinitions: any;
}) => {
const hasErrors = Object.entries(responses || {}).some(
([code]) => code.startsWith("4") || code.startsWith("5")
);
if (!hasErrors) return null;
return (
<div className="mb-8">
<h4 className="text-xl text-neutral-text mb-2">Errors</h4>
<div>
{Object.entries(responses || {})
.filter(([code]) => code.startsWith("4") || code.startsWith("5"))
.map(([code, response]: [string, any], index: number) => {
const isErrorResponse = true;
const responseKey = `${endpoint.method}-${endpoint.path}-${code}`;
const hasExpandableContent =
response &&
((response.content && Object.keys(response.content).length > 0) ||
response.schema ||
(typeof response === "object" &&
Object.keys(response).some((k) => k !== "description")));
const view = responseView[responseKey] || "schema";
const setView = (v: "schema" | "example") =>
setResponseView((prev) => ({
...prev,
[responseKey]: v,
}));
return (
<div key={code}>
<div
className={`px-3 py-1 ${
hasExpandableContent
? "cursor-pointer hover:bg-opacity-80 transition-colors"
: ""
}`}
onClick={
hasExpandableContent
? () => {
const newExpandedResponses = new Map(
expandedResponses
);
newExpandedResponses.set(
responseKey,
!expandedResponses.get(responseKey)
);
setExpandedResponses(newExpandedResponses);
}
: undefined
}
title={
hasExpandableContent
? "Click to expand/collapse"
: undefined
}
>
<div className="flex items-center w-full">
<span className="px-2 py-0.5 rounded-md inline-block bg-[#FEE2E2] font-bold text-red-800">
{code}
</span>
{response.description && (
<span className="ml-2 text-neutral-text">
{response.description}
</span>
)}
{hasExpandableContent && (
<div className="flex items-center gap-2 ml-2">
<RequestBodyDropdown value={view} onChange={setView} />
<span
className="ml-auto text-2xl font-bold select-none pointer-events-none flex items-center"
style={{ minWidth: 28 }}
>
{expandedResponses.get(responseKey) ? "" : "+"}
</span>
</div>
)}
</div>
</div>
{hasExpandableContent && expandedResponses.get(responseKey) && (
<div className="p-3">
<SchemaContext.Provider value={schemaDefinitions}>
{view === "schema" ? (
<SchemaType
schema={(() => {
if (
response.content &&
Object.keys(response.content).length > 0
) {
const firstContent = Object.values(
response.content
)[0] as any;
return firstContent.schema;
}
if (response.schema) {
return response.schema;
}
return {};
})()}
showExampleButton={false}
isErrorSchema={isErrorResponse}
/>
) : (
<CodeBlock
value={JSON.stringify(
(() => {
if (
response.content &&
Object.keys(response.content).length > 0
) {
const firstContent = Object.values(
response.content
)[0] as any;
return generateExample(
firstContent.schema,
schemaDefinitions
);
}
if (response.schema) {
return generateExample(
response.schema,
schemaDefinitions
);
}
return {};
})(),
null,
2
)}
lang="json"
/>
)}
</SchemaContext.Provider>
</div>
)}
</div>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,276 @@
import { createContext, useCallback, useEffect, useState } from "react";
import { tinaField } from "tinacms/dist/react";
import { EndpointSection } from "./endpoint-section";
import type {
ApiReferenceProps,
Endpoint,
ExpandedResponsesState,
ResponseViewState,
SchemaDetails,
} from "./types";
import { extractEndpoints, generateInitialExpandedState } from "./utils";
// Context to share schema definitions across components
export const SchemaContext = createContext<any>({});
export const ApiReference = (data: ApiReferenceProps) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [schemaDetails, setSchemaDetails] = useState<SchemaDetails | null>(
null
);
const [selectedEndpoint, setSelectedEndpoint] = useState<Endpoint | null>(
null
);
const [schemaDefinitions, setSchemaDefinitions] = useState<any>({});
const [expandedResponses, setExpandedResponses] =
useState<ExpandedResponsesState>(new Map());
const [requestBodyView, setRequestBodyView] = useState<"schema" | "example">(
"schema"
);
const [responseView, setResponseView] = useState<ResponseViewState>({});
const [isVisible, setIsVisible] = useState(false);
// Helper function to set empty schema state
const setEmptySchema = useCallback(() => {
setSchemaDetails({
title: "API Documentation",
version: "",
endpoints: [],
securityDefinitions: {},
});
setLoading(false);
}, []);
// Function to process schema data and extract endpoints
const processSchemaData = useCallback(
(schemaJson: any, endpointSelector: string) => {
// Store schema definitions for references
const definitions = {
definitions: schemaJson.definitions || {},
components: schemaJson.components || {},
// For OpenAPI 3.0
schemas: schemaJson.components?.schemas || {},
};
setSchemaDefinitions(definitions);
// Process the schema to extract endpoints
const endpoints: Endpoint[] = extractEndpoints(schemaJson);
// Set the schema details
setSchemaDetails({
title: schemaJson.info?.title || "API Documentation",
version: schemaJson.info?.version,
endpoints,
securityDefinitions: schemaJson.securityDefinitions || {},
});
// Find the selected endpoint if specified
if (endpointSelector) {
const [method, ...pathParts] = endpointSelector.split(":");
const path = pathParts.join(":"); // Rejoin in case path had colons
const endpoint = endpoints.find(
(e) => e.method === method && e.path === path
);
setSelectedEndpoint(endpoint || null);
}
},
[]
);
useEffect(() => {
if (!loading && schemaDetails) {
// Force a reflow to ensure the animation triggers
requestAnimationFrame(() => {
setIsVisible(true);
});
}
}, [loading, schemaDetails]);
const resetState = useCallback(() => {
setLoading(true);
setError(null);
setSchemaDetails(null);
setSelectedEndpoint(null);
setSchemaDefinitions({});
setExpandedResponses(new Map());
setResponseView({});
setIsVisible(false);
}, []);
useEffect(() => {
const fetchAndParseSchema = async () => {
try {
resetState();
// Parse the combined schema and endpoint value
let schemaPath = "";
let endpointSelector = "";
if (data.schemaFile && typeof data.schemaFile === "string") {
const parts = data.schemaFile.split("|");
schemaPath = parts[0];
if (parts.length > 1) {
endpointSelector = parts[1];
}
}
if (!schemaPath) {
setError("No schema file specified");
setLoading(false);
return;
}
// Fetch the schema file from API route
let schemaJson: any;
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/api/api-schema?relativePath=${encodeURIComponent(schemaPath)}`
);
if (!response.ok) {
setEmptySchema();
return;
}
const data = await response.json();
schemaJson = data.schema;
} catch (error) {
setEmptySchema();
return;
}
// Process the schema data
processSchemaData(schemaJson, endpointSelector);
setLoading(false);
} catch (error) {
setError("An error occurred while loading the API schema");
setLoading(false);
}
};
fetchAndParseSchema();
}, [data.schemaFile, resetState, setEmptySchema, processSchemaData]);
// Initialize expanded state for all endpoint responses
useEffect(() => {
if (schemaDetails?.endpoints) {
setExpandedResponses(
generateInitialExpandedState(schemaDetails.endpoints)
);
}
}, [schemaDetails]);
if (loading) {
return <Loading />;
}
if (error) {
return <ErrorMessage error={error} />;
}
if (!schemaDetails) {
return (
<div className="p-4 bg-yellow-50 rounded-md text-yellow-700">
<h3 className="font-medium">No API Schema</h3>
<p>Could not load API schema details.</p>
</div>
);
}
const hasEndpoints =
schemaDetails.endpoints && schemaDetails.endpoints.length > 0;
return (
<div
className={`api-reference ${
hasEndpoints ? "mb-12" : ""
} transform transition-all duration-700 ease-out ${
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
}`}
data-tina-field={tinaField(data, "schemaFile")}
>
<SchemaContext.Provider value={schemaDefinitions}>
{selectedEndpoint ? (
// Show only the selected endpoint
EndpointSection(
selectedEndpoint,
requestBodyView,
setRequestBodyView,
expandedResponses,
setExpandedResponses,
responseView,
setResponseView,
schemaDefinitions
)
) : (
// Show all endpoints
<div>
{hasEndpoints ? (
schemaDetails.endpoints.map((endpoint) =>
EndpointSection(
endpoint,
requestBodyView,
setRequestBodyView,
expandedResponses,
setExpandedResponses,
responseView,
setResponseView,
schemaDefinitions
)
)
) : (
<NoEndpointsFound />
)}
</div>
)}
</SchemaContext.Provider>
</div>
);
};
const NoEndpointsFound = () => {
return (
<div className="py-8 text-center">
<div className="bg-neutral-background-secondary border border-neutral-border/40 rounded-lg p-6">
<h3 className="text-lg font-medium text-neutral-text mb-2">
No API Endpoints Found
</h3>
<p className="text-neutral-text-secondary text-sm">
This API schema doesn't contain any endpoints to display or the file
is not found.
</p>
</div>
</div>
);
};
const Loading = () => {
return (
<div className="p-4">
<div className="animate-pulse flex space-x-4">
<div className="flex-1 space-y-4 py-1">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
</div>
</div>
</div>
</div>
);
};
const ErrorMessage = ({ error }: { error: string }) => {
return (
<div className="py-4">
<div className="bg-red-50 rounded-md text-red-700 p-4">
<h3 className="font-medium">Error</h3>
<p>{error}</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,94 @@
import { CodeBlock } from "../../standard-elements/code-block/code-block";
import { SchemaType } from "./scheme-type";
import type { RequestBodyDropdownProps } from "./types";
import { generateExample } from "./utils";
export const RequestBodySection = ({
requestBody,
requestBodyView,
setRequestBodyView,
schemaDefinitions,
}: {
requestBody: any;
requestBodyView: "schema" | "example";
setRequestBodyView: (view: "schema" | "example") => void;
schemaDefinitions: any;
}) => {
if (!requestBody) return null;
return (
<div className="mb-8">
<div className="flex items-center justify-between gap-2 mb-2">
<h4 className="text-xl text-neutral-text">Request Body</h4>
<RequestBodyDropdown
value={requestBodyView}
onChange={setRequestBodyView}
/>
</div>
{requestBody.description && (
<p className="text-neutral-text mb-2">{requestBody.description}</p>
)}
<div>
{requestBodyView === "schema" ? (
<SchemaType
schema={
requestBody.content
? (Object.values(requestBody.content)[0] as any).schema
: requestBody.schema
}
showExampleButton={false}
isErrorSchema={false}
name={(() => {
const schema = requestBody.content
? (Object.values(requestBody.content)[0] as any).schema
: requestBody.schema;
if (schema?.type === "array") {
return "Array of object";
}
return undefined;
})()}
/>
) : (
<CodeBlock
value={JSON.stringify(
generateExample(
requestBody.content
? (Object.values(requestBody.content)[0] as any).schema
: requestBody.schema,
schemaDefinitions
),
null,
2
)}
lang="json"
/>
)}
</div>
</div>
);
};
export const RequestBodyDropdown = ({
value,
onChange,
}: RequestBodyDropdownProps) => {
return (
<div className="relative inline-block text-left">
<select
tabIndex={-1}
className="border-[0.25px] border-neutral-border rounded px-2 text-sm text-neutral-text-secondary bg-neutral-background min-w-[100px] flex items-center justify-between gap-2"
value={value}
onClick={(e) => {
// Prevent click event from bubbling up
e.stopPropagation();
}}
onChange={(e) => {
onChange(e.target.value as "schema" | "example");
}}
>
<option value="schema">Schema</option>
<option value="example">Example</option>
</select>
</div>
);
};

View File

@@ -0,0 +1,182 @@
import { SchemaContext } from ".";
import { CodeBlock } from "../../standard-elements/code-block/code-block";
import { RequestBodyDropdown } from "./request-body-section";
import { ChevronIcon, SchemaType } from "./scheme-type";
import type {
Endpoint,
ExpandedResponsesState,
ResponseViewState,
} from "./types";
import { generateExample } from "./utils";
export const ResponseBodySection = ({
responses,
endpoint,
expandedResponses,
setExpandedResponses,
responseView,
setResponseView,
schemaDefinitions,
}: {
responses: any;
endpoint: Endpoint;
expandedResponses: ExpandedResponsesState;
setExpandedResponses: (state: ExpandedResponsesState) => void;
responseView: ResponseViewState;
setResponseView: (
updater: (prev: ResponseViewState) => ResponseViewState
) => void;
schemaDefinitions: any;
}) => {
const nonErrorResponses = Object.entries(responses || {}).filter(
([code]) => !code.startsWith("4") && !code.startsWith("5")
);
if (nonErrorResponses.length === 0) {
return (
<div className="mb-8">
<h4 className="text-xl text-neutral-text mb-2">Responses</h4>
<div className="pl-3 text-neutral-text-secondary text-sm">
No responses defined for this endpoint.
</div>
</div>
);
}
return (
<div className="mb-8">
<h4 className="text-xl text-neutral-text mb-2">Responses</h4>
<div className="space-y-4">
{nonErrorResponses.map(([code, response]: [string, any]) => {
const isErrorResponse = code.startsWith("4") || code.startsWith("5");
const responseKey = `${endpoint.method}-${endpoint.path}-${code}`;
const hasExpandableContent =
response &&
((response.content && Object.keys(response.content).length > 0) ||
response.schema ||
(typeof response === "object" &&
Object.keys(response).some((k) => k !== "description")));
const view = responseView[responseKey] || "schema";
const setView = (v: "schema" | "example") =>
setResponseView((prev) => ({
...prev,
[responseKey]: v,
}));
return (
<div key={code}>
<div
className={`p-3 ${
hasExpandableContent ? "cursor-pointer" : ""
}`}
onClick={
hasExpandableContent
? () => {
const newExpandedResponses = new Map(expandedResponses);
newExpandedResponses.set(
responseKey,
!expandedResponses.get(responseKey)
);
setExpandedResponses(newExpandedResponses);
}
: undefined
}
title={
hasExpandableContent ? "Click to expand/collapse" : undefined
}
>
<div className="flex items-center w-full justify-between gap-2">
<div className="flex items-center gap-2">
<span
className={`px-2 py-0.5 rounded-md inline-block ${
code.startsWith("2")
? "bg-[#B4EFD9] text-green-800 font-bold"
: isErrorResponse
? "bg-red-100 text-red-800"
: "bg-gray-200 text-gray-800 font-tuner text-center"
}`}
>
{code}
</span>
{response.description && (
<span className="ml-2 text-neutral-text flex items-center gap-2">
{response.description}
</span>
)}
{hasExpandableContent && (
<ChevronIcon
isExpanded={expandedResponses.get(responseKey) || false}
/>
)}
</div>
{hasExpandableContent && (
<div className="ml-auto relative">
<RequestBodyDropdown value={view} onChange={setView} />
</div>
)}
</div>
</div>
{hasExpandableContent && expandedResponses.get(responseKey) && (
<div className="pb-3 px-3">
<SchemaContext.Provider value={schemaDefinitions}>
{view === "schema" ? (
<SchemaType
schema={(() => {
if (
response.content &&
Object.keys(response.content).length > 0
) {
const firstContent = Object.values(
response.content
)[0] as any;
return firstContent.schema;
}
if (response.schema) {
return response.schema;
}
return {};
})()}
showExampleButton={false}
isErrorSchema={isErrorResponse}
/>
) : (
<CodeBlock
value={JSON.stringify(
(() => {
if (
response.content &&
Object.keys(response.content).length > 0
) {
const firstContent = Object.values(
response.content
)[0] as any;
return generateExample(
firstContent.schema,
schemaDefinitions
);
}
if (response.schema) {
return generateExample(
response.schema,
schemaDefinitions
);
}
return {};
})(),
null,
2
)}
lang="json"
/>
)}
</SchemaContext.Provider>
</div>
)}
</div>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,381 @@
import React, { useEffect, useState, useContext, createContext } from "react";
import { FaChevronRight } from "react-icons/fa";
import type { IconBaseProps } from "react-icons/lib/iconBase";
import type { ChevronIconProps, SchemaTypeProps } from "./types";
import { resolveReference } from "./utils";
export const ChevronIcon = ({ isExpanded }: ChevronIconProps) => {
const Icon = FaChevronRight as React.ComponentType<IconBaseProps>;
return (
<Icon
className={`text-neutral-text transition-transform duration-200 ${
isExpanded ? "rotate-90" : ""
}`}
style={{ width: "10px", height: "10px" }}
/>
);
};
const SchemaContext = createContext<any>({});
// Type rendering component for recursively displaying schema structures
export const SchemaType = ({
schema,
depth = 0,
isNested = false,
name = "",
showExampleButton = false,
onToggleExample = () => {},
isErrorSchema = false,
}: SchemaTypeProps) => {
const [isExpanded, setIsExpanded] = useState(false); // Auto-expand first two levels and error schemas
const definitions = useContext(SchemaContext);
// Handle null schema
if (!schema) return <span className="text-gray-500">-</span>;
// Handle reference schemas
if (schema.$ref) {
const refPath = schema.$ref;
const refName = refPath.split("/").pop();
const refSchema = resolveReference(refPath, definitions);
// Check if this is likely an error schema by name
const probableErrorSchema =
refName &&
(refName.toLowerCase().includes("error") ||
refName.toLowerCase().includes("problem") ||
refName.toLowerCase().includes("exception"));
return (
<div className="ml-4">
<div className="ml-4">
<button
type="button"
className="flex items-center w-full cursor-pointer group"
onClick={() => setIsExpanded(!isExpanded)}
aria-label={isExpanded ? "Collapse" : "Expand"}
onKeyPress={(e) => {
if (e.key === "Enter" || e.key === " ")
setIsExpanded(!isExpanded);
}}
>
<span className="flex items-end">
<span
className={`group-hover:underline${
isErrorSchema || probableErrorSchema ? " text-red-600" : ""
}${name === "Array of object" ? " ml-4" : ""}`}
>
{refName}
</span>
{refSchema?.type && (
<span className="ml-2 text-xs font-mono text-neutral-text-secondary px-2 pb-0.5 rounded">
{refSchema.type}
</span>
)}
</span>
{showExampleButton && (
<button
type="button"
className="ml-4 text-xs text-neutral-text-secondary hover:underline"
onClick={(e) => {
e.stopPropagation();
onToggleExample();
}}
tabIndex={-1}
>
JSON Schema Example
</button>
)}
{refSchema && (
<span className="ml-2 flex items-center">
<ChevronIcon isExpanded={isExpanded} />
</span>
)}
</button>
</div>
{isExpanded &&
refSchema &&
refSchema.type === "object" &&
refSchema.properties && (
<div className="mt-1 pl-4">
{Object.entries(refSchema.properties).map(
([propName, propSchema]) => (
<div key={propName}>
<SchemaType
schema={propSchema as any}
name={propName}
depth={depth + 1}
isNested={true}
/>
</div>
)
)}
</div>
)}
{isExpanded && refSchema && refSchema.type !== "object" && (
<div className="mt-1 pl-4">
<SchemaType schema={refSchema} depth={depth + 1} isNested={true} />
</div>
)}
</div>
);
}
// Get type or infer it from properties
const type =
schema.type ||
(schema.properties ? "object" : schema.items ? "array" : "unknown");
// Check for common error fields
const hasErrorFields =
schema.properties &&
(schema.properties.error ||
schema.properties.message ||
schema.properties.code ||
schema.properties.errors ||
schema.properties.detail);
// Complex objects and arrays
const isArrayOfObjects =
type === "array" &&
schema.items &&
(schema.items.properties || schema.items.$ref);
const isExpandable =
schema.$ref ||
(type === "object" &&
schema.properties &&
Object.keys(schema.properties).length > 0) ||
isArrayOfObjects;
// If not expandable, show primitive type info
if (!isExpandable) {
return (
<div className={`${isNested ? "ml-4" : ""}`}>
<div className="flex items-end">
{name && (
<span
className={`text-neutral-text mr-2${
name === "Array of object" ? " ml-4" : ""
}`}
>
{name}
</span>
)}
<span className="text-xs font-mono text-neutral-text-secondary px-2 pb-0.5 rounded">
{type}
{schema.format ? ` (${schema.format})` : ""}
</span>
{schema.enum && (
<span className="ml-2 py-0.5 text-xs text-neutral-text-secondary font-mono">
enum: [{schema.enum.map((v: any) => JSON.stringify(v)).join(", ")}
]
</span>
)}
</div>
{schema.description && (
<div className="text-sm text-neutral-text-secondary mb-2">
{schema.description}
</div>
)}
</div>
);
}
return (
<div className={`${isNested ? "ml-4" : ""}`}>
<div
className={`flex items-center w-full${
isExpandable ? " cursor-pointer group" : ""
}`}
onClick={isExpandable ? () => setIsExpanded(!isExpanded) : undefined}
aria-label={
isExpandable ? (isExpanded ? "Collapse" : "Expand") : undefined
}
tabIndex={isExpandable ? 0 : -1}
role={isExpandable ? "button" : undefined}
onKeyPress={
isExpandable
? (e) => {
if (e.key === "Enter" || e.key === " ")
setIsExpanded(!isExpanded);
}
: undefined
}
>
<div className="flex items-center">
<div className="flex items-end gap-4">
{name && (
<span
className={`group-hover:underline${
isErrorSchema || hasErrorFields ? " text-red-600" : ""
}${name === "Array of object" ? " ml-4" : ""}`}
>
{name}
</span>
)}
{type === "array" && schema.items && (
<span className="text-neutral-text-secondary pb-0.5 font-mono text-xs">
{schema.items && (schema.items.properties || schema.items.$ref)
? "array [object]"
: `array [${schema.items?.type ? schema.items.type : "any"}]`}
</span>
)}
{type === "object" && (
<span className="text-neutral-text-secondary font-mono text-xs">
object
</span>
)}
{isExpandable && (
<div className="pb-1">
<ChevronIcon isExpanded={isExpanded} />
</div>
)}
</div>
</div>
{showExampleButton && depth === 0 && type === "array" && (
<button
type="button"
className="ml-2 text-xs text-neutral-text hover:underline focus:outline-none"
onClick={(e) => {
e.stopPropagation();
onToggleExample();
}}
>
JSON Schema Example
</button>
)}
{showExampleButton && depth === 0 && type !== "array" && (
<button
type="button"
className="ml-auto text-xs text-neutral-text hover:underline focus:outline-none"
onClick={(e) => {
e.stopPropagation();
onToggleExample();
}}
style={{ marginLeft: "auto" }}
>
JSON Schema Example
</button>
)}
{showExampleButton && depth !== 0 && (
<button
type="button"
className="ml-2 text-xs text-blue-600 hover:underline focus:outline-none"
onClick={(e) => {
e.stopPropagation();
onToggleExample();
}}
>
Show example
</button>
)}
</div>
{/* Animated expandable content */}
<div
className={`transition-all duration-300 overflow-hidden ${
isExpanded ? "opacity-100" : "opacity-0"
}`}
>
{isExpanded && (
<div className="pl-4">
{type === "object" && schema.properties && (
<div>
{Object.entries(schema.properties).map(
([propName, propSchema]: [string, any]) => {
// Determine type and format
const propType =
propSchema.type ||
(propSchema.properties
? "object"
: propSchema.items
? "array"
: "unknown");
const format = propSchema.format;
const isArray = propType === "array";
const itemType = isArray
? propSchema.items?.type ||
(propSchema.items?.properties ? "object" : "any")
: null;
const enumVals = propSchema.enum;
const isObject =
propType === "object" && propSchema.properties;
return (
<React.Fragment key={propName}>
<div className="flex items-center mb-1">
<span className="font-mono text-neutral-text mr-2">
{propName}
</span>
<span className="text-xs font-mono text-neutral-text px-2 py-0.5 rounded">
{isArray ? `[${itemType}]` : propType}
{format ? ` (${format})` : ""}
</span>
{enumVals && (
<span className="ml-2 text-xs text-neutral-text">
enum: [
{enumVals
.map((v: any) => JSON.stringify(v))
.join(", ")}
]
</span>
)}
</div>
{propSchema.description && (
<div className="text-sm text-neutral-text-secondary ml-2 mb-1">
{propSchema.description}
</div>
)}
{isObject && (
<div className="ml-4">
<SchemaType
schema={propSchema}
depth={depth + 1}
isNested={true}
name={propName}
/>
</div>
)}
</React.Fragment>
);
}
)}
{!Object.keys(schema.properties).length && (
<span className="text-gray-500 italic">Empty object</span>
)}
</div>
)}
{isArrayOfObjects && (
<div>
<SchemaType
schema={schema.items}
depth={depth + 1}
isNested={true}
isErrorSchema={isErrorSchema}
/>
</div>
)}
{schema.additionalProperties && (
<div className="mt-2">
<div className="text-gray-800 font-medium">
Additional properties:
</div>
<SchemaType
schema={
typeof schema.additionalProperties === "boolean"
? { type: "any" }
: schema.additionalProperties
}
depth={depth + 1}
isNested={true}
isErrorSchema={isErrorSchema}
/>
</div>
)}
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,55 @@
// Interface for endpoint details
export interface Endpoint {
path: string;
method: string;
summary: string;
description?: string;
operationId?: string;
parameters?: any[];
responses?: Record<string, any>;
tags?: string[];
requestBody?: any;
security?: any[];
}
// Interface for parsed Swagger/OpenAPI details
export interface SchemaDetails {
title?: string;
version?: string;
endpoints: Endpoint[];
securityDefinitions?: Record<string, any>;
}
// Props for the SchemaType component
export interface SchemaTypeProps {
schema: any;
depth?: number;
isNested?: boolean;
name?: string;
showExampleButton?: boolean;
onToggleExample?: () => void;
isErrorSchema?: boolean;
}
// Props for the ChevronIcon component
export interface ChevronIconProps {
isExpanded: boolean;
}
// Props for the RequestBodyDropdown component
export interface RequestBodyDropdownProps {
value: "schema" | "example";
onChange: (v: "schema" | "example") => void;
}
// Props for the ApiReference component
export interface ApiReferenceProps {
schemaFile?: string;
[key: string]: any;
}
// Type for response view state
export type ResponseViewState = Record<string, "schema" | "example">;
// Type for expanded responses state
export type ExpandedResponsesState = Map<string, boolean>;

View File

@@ -0,0 +1,162 @@
import type { Endpoint, ExpandedResponsesState } from "./types";
export const resolveReference = (ref: string, definitions: any): any => {
if (!ref || typeof ref !== "string" || !ref.startsWith("#/")) {
return null;
}
// Extract the path from the reference
const path = ref.substring(2).split("/");
// Navigate through the definitions object
let result = definitions;
for (const segment of path) {
if (!result || !result[segment]) {
return null;
}
result = result[segment];
}
return result;
};
export const generateExample = (
schema: any,
definitions: any,
depth = 0
): any => {
if (depth > 3) return "...";
if (schema.$ref) {
const refSchema = resolveReference(schema.$ref, definitions);
if (refSchema) {
return generateExample(refSchema, definitions, depth);
}
return `<${schema.$ref.split("/").pop()}>`;
}
switch (schema.type) {
case "string":
return schema.example || schema.default || "string";
case "integer":
case "number":
return schema.example || schema.default || 0;
case "boolean":
return schema.example || schema.default || false;
case "array":
if (schema.items) {
const itemExample = generateExample(
schema.items,
definitions,
depth + 1
);
return [itemExample];
}
return [];
case "object":
if (schema.properties) {
const obj: any = {};
for (const [key, prop] of Object.entries(schema.properties)) {
obj[key] = generateExample(prop, definitions, depth + 1);
}
return obj;
}
return {};
default:
if (schema.properties) {
const obj: any = {};
for (const [key, prop] of Object.entries(schema.properties)) {
obj[key] = generateExample(prop, definitions, depth + 1);
}
return obj;
}
return null;
}
};
export const extractEndpoints = (schemaJson: any) => {
const endpoints: Endpoint[] = [];
if (schemaJson.paths) {
for (const path of Object.keys(schemaJson.paths)) {
const pathObj = schemaJson.paths[path];
for (const method of Object.keys(pathObj)) {
if (method === "parameters") continue; // Skip path-level parameters
const operation = pathObj[method];
// Handle request body for OpenAPI 3.0 or Swagger 2.0
let requestBody: any = undefined;
if (operation.requestBody) {
requestBody = operation.requestBody;
} else if (operation.parameters?.some((p: any) => p.in === "body")) {
requestBody = {
content: {
"application/json": {
schema:
operation.parameters.find((p: any) => p.in === "body")
?.schema || {},
},
},
};
}
// Filter out body parameters if we have a request body
const parameters = [
...(pathObj.parameters || []), // Include path-level parameters
...(operation.parameters || []),
].filter((p) => {
// If we have a request body from a body parameter, filter out that parameter
if (requestBody && p.in === "body") {
return false;
}
return true;
});
endpoints.push({
path,
method: method.toUpperCase(),
summary: operation.summary || `${method.toUpperCase()} ${path}`,
description: operation.description,
operationId: operation.operationId,
parameters,
responses: operation.responses,
requestBody,
tags: operation.tags,
security: operation.security,
});
}
}
}
return endpoints;
};
// Generates the initial expanded/collapsed state for endpoint responses.
// A response will be expanded by default when:
// 1. The response object contains expandable content (schema, content, etc.)
// The returned Map is keyed by `<METHOD>-<PATH>-<STATUS_CODE>` and the value
// indicates whether the response should start expanded (true) or collapsed (false).
export const generateInitialExpandedState = (
endpoints: Endpoint[]
): ExpandedResponsesState => {
const initialState: ExpandedResponsesState = new Map();
for (const endpoint of endpoints) {
if (!endpoint.responses) continue;
for (const [code, response] of Object.entries(endpoint.responses)) {
const key = `${endpoint.method}-${endpoint.path}-${code}`;
// Cast response to any for property access convenience
const resp = response as any;
const hasExpandableContent =
resp &&
((resp.content && Object.keys(resp.content).length > 0) || // OpenAPI 3.x style
resp.schema || // Swagger 2.0 style
(typeof resp === "object" &&
Object.keys(resp).some((k) => k !== "description")));
initialState.set(key, hasExpandableContent);
}
}
return initialState;
};

View File

@@ -0,0 +1,92 @@
import { IoMdInformationCircle } from "react-icons/io";
import { IoMdWarning } from "react-icons/io";
import { LuChevronsLeftRight } from "react-icons/lu";
import { MdLightbulb } from "react-icons/md";
import { MdLock } from "react-icons/md";
import { MdOutlineCheck } from "react-icons/md";
import { RxCross2 } from "react-icons/rx";
import { tinaField } from "tinacms/dist/react";
import { TinaMarkdown, type TinaMarkdownContent } from "tinacms/dist/rich-text";
import { MarkdownComponentMapping } from "../markdown-component-mapping";
type CalloutVariant =
| "warning"
| "info"
| "success"
| "error"
| "idea"
| "lock"
| "api";
const variants = {
warning: "border-x-amber-500",
info: "border-x-brand-secondary",
success: "border-x-green-600",
error: "border-x-red-500",
idea: "border-x-brand-tertiary-hover",
lock: "border-x-neutral-text-secondary",
api: "border-x-brand-primary",
} as const;
const icons = {
warning: IoMdWarning,
info: IoMdInformationCircle,
success: MdOutlineCheck,
error: RxCross2,
idea: MdLightbulb,
lock: MdLock,
api: LuChevronsLeftRight,
} as const;
const iconColors = {
warning: "text-amber-500",
info: "text-brand-secondary",
success: "text-green-600",
error: "text-red-500",
idea: "text-brand-tertiary-hover",
lock: "text-neutral-text-secondary",
api: "text-brand-primary",
} as const;
interface CalloutProps {
body?: TinaMarkdownContent;
variant?: CalloutVariant;
text?: any;
}
const Callout = (props) => {
const { body, variant = "warning", text }: CalloutProps = props;
const Icon = icons[variant] || icons.info;
const variantClass = variants[variant] || variants.info;
const iconColorClass = iconColors[variant] || iconColors.info;
return (
<blockquote
className={`relative overflow-hidden rounded-lg bg-neutral-background-secondary border-l-4 my-4 shadow-sm ${variantClass} `}
>
<div className="flex items-start gap-3 px-4">
<div
className="relative top-5 left-1"
data-tina-field={tinaField(props, "variant")}
>
<Icon className={`${iconColorClass}`} size={20} />
</div>
<div
className={`leading-6 text-neutral-text font-light py-2 ${
text ? "my-2.5" : ""
}`}
data-tina-field={tinaField(props, "body")}
>
<TinaMarkdown
content={
(body as TinaMarkdownContent) || (text as TinaMarkdownContent)
}
components={MarkdownComponentMapping}
/>
</div>
</div>
</blockquote>
);
};
export default Callout;

View File

@@ -0,0 +1,71 @@
import Link from "next/link";
import { tinaField } from "tinacms/dist/react";
export const CardGrid = (data: {
cards: {
title: string;
description: string;
link: string;
linkText: string;
}[];
}) => {
const cardClasses =
"relative border border-neutral-border bg-neutral-background/75 rounded-lg group p-6 shadow-lg hover:bg-gradient-to-br hover:from-transparent hover:via-transparent hover:to-brand-secondary-hover/15 dark:hover:bg-gradient-to-br dark:hover:from-transparent dark:hover:via-brand-secondary/10 dark:hover:to-brand-secondary/50 transition-all duration-300";
return (
<div className="my-8 grid grid-cols-1 rounded-lg gap-4 lg:grid-cols-2">
{data.cards?.map((card, index) => {
if (card.link) {
return (
<Link
href={card.link}
className={cardClasses}
key={`card-${index}-${card.title}`}
>
<h2
className="text-2xl font-medium brand-primary-gradient mb-2 font-heading"
data-tina-field={tinaField(data.cards[index], "title")}
>
{card.title}
</h2>
<p
className="text-neutral-text font-light mb-10 font-body"
data-tina-field={tinaField(data.cards[index], "description")}
>
{card.description}
</p>
{card.link && (
<p className="flex items-center absolute bottom-4">
<span
className="relative brand-secondary-gradient"
data-tina-field={tinaField(data.cards[index], "linkText")}
>
{card.linkText ?? "See more"}
<span className="absolute bottom-0 left-0 w-0 h-[1.5px] bg-gradient-to-r from-brand-secondary-gradient-start to-brand-secondary-gradient-end group-hover:w-full transition-all duration-300 ease-in-out" />
</span>
<span className="ml-1 mr-2 brand-secondary-gradient"> </span>
</p>
)}
</Link>
);
}
return (
<div className={cardClasses} key={`card-${index}-${card.title}`}>
<h2
className="text-2xl font-medium brand-primary-gradient mb-2 font-heading"
data-tina-field={tinaField(data.cards[index], "title")}
>
{card.title}
</h2>
<p
className="text-neutral-text font-light mb-4 font-body"
data-tina-field={tinaField(data.cards[index], "description")}
>
{card.description}
</p>
</div>
);
})}
</div>
);
};

View File

@@ -0,0 +1,165 @@
/* Duotone Sea theme by Simurai */
code[class*="language-"],
pre[class*="language-"] {
font-family:
Consolas, Menlo, Monaco, "Andale Mono WT", "Andale Mono", "Lucida Console", "Lucida Sans Typewriter", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", "Liberation Mono", "Nimbus Mono L", "Courier New", Courier, monospace;
font-size: 14px;
line-height: 1.375;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
tab-size: 4;
hyphens: none;
color: #f5f5f5;
}
pre[class*="language-"]::-moz-selection,
pre[class*="language-"] ::-moz-selection,
code[class*="language-"]::-moz-selection,
code[class*="language-"] ::-moz-selection {
text-shadow: none;
background: #004a9e;
}
pre[class*="language-"]::selection,
pre[class*="language-"] ::selection,
code[class*="language-"]::selection,
code[class*="language-"] ::selection {
text-shadow: none;
background: #004a9e;
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: .5em 0;
overflow: auto;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: .1em;
border-radius: .3em;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #4a5f78;
}
.token.punctuation {
color: #ed9ff1;
}
.token.namespace {
opacity: 0.7;
}
.token.tag,
.token.operator,
.token.number {
color: #f2d8a4;
}
.token.property,
.token.function {
color: #ea580c;
}
.token.tag-id,
.token.selector,
.token.atrule-id {
color: #ebf4ff;
}
.token.attr-name {
color: #7eb6f6;
}
.token.boolean,
.token.string,
.token.entity,
.token.url,
.language-css .token.string,
.language-scss .token.string,
.style .token.string,
.token.attr-value,
.token.keyword,
.token.control,
.token.directive,
.token.unit,
.token.statement,
.token.regex,
.token.atrule {
color: #eecba8;
}
.token.placeholder,
.token.variable {
color: #47ebb4;
}
.token.deleted {
text-decoration: line-through;
}
.token.inserted {
border-bottom: 1px dotted #ebf4ff;
text-decoration: none;
}
.token.italic {
font-style: italic;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.important {
color: #7eb6f6;
}
.token.entity {
cursor: help;
}
pre > code.highlight {
outline: .4em solid #34659d;
outline-offset: .4em;
}
/* Line numbers */
.line-numbers .line-numbers-rows {
position: absolute;
pointer-events: none;
top: 0;
font-size: 100%;
left: -3.8em;
width: 3em;
letter-spacing: -1px;
border-right: 1px solid #fdfdfd;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.line-numbers-rows > span {
display: block;
counter-increment: linenumber;
}
.line-numbers-rows > span:before {
content: counter(linenumber);
color: #4a5f78;
display: block;
padding-right: 0.8em;
text-align: right;
}

View File

@@ -0,0 +1,183 @@
import { CheckIcon as CheckIconOutline } from "@heroicons/react/24/outline";
import { useEffect, useRef, useState } from "react";
import React from "react";
import { MdContentCopy } from "react-icons/md";
import { CodeBlock } from "../standard-elements/code-block/code-block";
import { CodeBlockSkeleton } from "../standard-elements/code-block/code-block-skeleton";
interface Tab {
name: string;
content: string;
id?: string;
language?: string;
}
interface CodeTabsProps {
tabs: Tab[];
initialSelectedIndex?: number;
}
export const CodeTabs = ({ tabs, initialSelectedIndex = 0 }: CodeTabsProps) => {
const [selectedTabIndex, setSelectedTabIndex] = useState(
initialSelectedIndex > tabs.length ? 0 : initialSelectedIndex
);
const [height, setHeight] = useState(0);
const [hasCopied, setHasCopied] = useState(false);
const [isTransitioning, setIsTransitioning] = useState(true);
const contentRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<(HTMLDivElement | null)[]>([]);
// Initialize tab refs array
useEffect(() => {
tabRefs.current = tabRefs.current.slice(0, tabs.length);
}, [tabs.length]);
useEffect(() => {
const updateHeight = () => {
const activeRef = tabRefs.current[selectedTabIndex];
if (activeRef) {
setHeight(activeRef.scrollHeight);
}
};
updateHeight();
const resizeObserver = new ResizeObserver(updateHeight);
const activeRef = tabRefs.current[selectedTabIndex];
if (activeRef) {
resizeObserver.observe(activeRef);
}
return () => {
resizeObserver.disconnect();
};
}, [selectedTabIndex]);
// Handle tab switching with transition
const handleTabSwitch = (newTabIndex: number) => {
if (newTabIndex !== selectedTabIndex) {
setSelectedTabIndex(newTabIndex);
}
};
// Handle the copy action
const handleCopy = () => {
const textToCopy = tabs[selectedTabIndex]?.content;
navigator.clipboard.writeText(textToCopy || "");
setHasCopied(true);
setTimeout(() => setHasCopied(false), 2000);
};
const buttonStyling =
"flex justify-center relative leading-tight text-neutral-text py-[8px] text-base font-bold transition duration-150 ease-out rounded-t-3xl flex items-center gap-1 whitespace-nowrap px-6";
const activeButtonStyling =
" hover:text-neutral-text-secondary opacity-50 hover:opacity-100";
const underlineStyling =
"transition-[width] absolute h-0.5 -bottom-0.25 bg-brand-primary rounded-lg";
// Early return if no tabs provided
if (!tabs || tabs.length === 0) {
return null;
}
return (
<div className="my-8">
<style>{`
.query-response-pre pre,
.query-response-pre code {
white-space: pre !important;
tab-size: 2;
}
`}</style>
<div className="flex flex-col z-10 w-full rounded-xl py-0 bg-neutral-background shadow-lg border border-neutral-border">
{/* TOP SECTION w/ Buttons */}
<div className="flex items-center w-full border-b border-neutral-border ">
<div className="flex flex-1 ">
{tabs?.map((tab, index) => (
<button
key={tab.id || index}
type="button"
onClick={() => handleTabSwitch(index)}
className={`${buttonStyling}${
selectedTabIndex === index ? "" : activeButtonStyling
}${isTransitioning ? " cursor-not-allowed" : " cursor-pointer"}`}
disabled={selectedTabIndex === index || isTransitioning}
>
{tab.name}
<div
className={
underlineStyling +
(selectedTabIndex === index ? " w-full" : " w-0")
}
/>
</button>
))}
</div>
{/* Copy Button */}
<div className="flex pr-2">
<button
type="button"
onClick={handleCopy}
disabled={isTransitioning}
className={`flex items-center gap-1.5 text-sm font-medium text-neutral-text-secondary transition-colors duration-200 px-2 py-1 rounded hover:bg-white/10 ${
isTransitioning
? "opacity-50 cursor-not-allowed"
: "cursor-pointer"
}`}
title={`Copy ${tabs[selectedTabIndex]?.name.toLowerCase()} code`}
>
{hasCopied ? (
<>
<CheckIconOutline className="h-4 w-4" />
<span>Copied!</span>
</>
) : (
<>
<MdContentCopy className="h-4 w-4" />
</>
)}
</button>
</div>
</div>
{/* BOTTOM SECTION w/ Tab Content */}
{isTransitioning && <CodeBlockSkeleton hasTabs={true} />}
<div
className="overflow-hidden rounded-b-xl"
hidden={isTransitioning}
style={{ height: `${height}px` }}
>
<div
ref={contentRef}
className="font-light font-mono text-xs text-neutral-text hover:text-neutral-text-secondary relative query-response-pre"
style={{
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontWeight: 300,
whiteSpace: "pre",
}}
>
{tabs.map((tab, index) => (
<div
key={tab.id || index}
ref={(el) => {
tabRefs.current[index] = el;
}}
className="relative -mt-2"
style={{
display: selectedTabIndex === index ? "block" : "none",
}}
>
<CodeBlock
value={tab.content?.replaceAll("<22>", " ")}
lang={tab.language ?? "text"}
showCopyButton={false}
showBorder={false}
setIsTransitioning={setIsTransitioning}
/>
</div>
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,195 @@
import {
ChevronDownIcon,
ChevronRightIcon,
DocumentIcon,
FolderIcon,
FolderOpenIcon,
} from "@heroicons/react/24/outline";
import { useState } from "react";
import { tinaField } from "tinacms/dist/react";
interface FileItem {
id: string;
name: string;
type: "file" | "folder";
parentId: string | null;
}
interface FileStructureProps {
fileStructure: FileItem[];
caption?: string;
}
interface TreeNode extends FileItem {
children: TreeNode[];
level: number;
}
const SPACING = {
LEVEL_INDENT: 20,
BASE_PADDING: 8,
} as const;
const buildTree = (items: FileItem[]): TreeNode[] => {
const itemMap = new Map<string, TreeNode>();
const rootItems: TreeNode[] = [];
for (const item of items) {
itemMap.set(item.id, { ...item, children: [], level: 0 });
}
for (const item of items) {
const node = itemMap.get(item.id);
if (!node) continue;
if (item.parentId === null) {
rootItems.push(node);
} else {
const parent = itemMap.get(item.parentId);
if (parent) {
parent.children.push(node);
node.level = parent.level + 1;
}
}
}
const sortNodes = (nodes: TreeNode[]) => {
nodes.sort((a, b) =>
a.type !== b.type
? a.type === "folder"
? -1
: 1
: a.name.localeCompare(b.name)
);
for (const node of nodes) sortNodes(node.children);
};
sortNodes(rootItems);
return rootItems;
};
interface FileTreeItemProps {
node: TreeNode;
expandedFolders: Set<string>;
toggleFolder: (id: string) => void;
}
const FileTreeItem = ({
node,
expandedFolders,
toggleFolder,
}: FileTreeItemProps) => {
const isExpanded = expandedFolders.has(node.id);
const hasChildren = node.children.length > 0;
return (
<div>
<div
className={[
"flex items-center gap-2 py-1 hover:bg-neutral-background-secondary rounded text-sm w-fit min-w-full",
node.type === "folder" && hasChildren && "cursor-pointer",
]
.filter(Boolean)
.join(" ")}
style={{
paddingLeft: `${node.level * SPACING.LEVEL_INDENT + SPACING.BASE_PADDING}px`,
}}
onClick={() =>
node.type === "folder" && hasChildren && toggleFolder(node.id)
}
>
{/* Expand/Collapse Icon */}
{node.type === "folder" && hasChildren && (
<div className="flex-shrink-0">
{isExpanded ? (
<ChevronDownIcon className="h-4 w-4 text-neutral-text-secondary" />
) : (
<ChevronRightIcon className="h-4 w-4 text-neutral-text-secondary" />
)}
</div>
)}
{/* Spacing for files or folders without children */}
{(node.type === "file" || !hasChildren) && (
<div className="w-4 flex-shrink-0" />
)}
{/* File/Folder Icon */}
<div className="flex-shrink-0">
{node.type === "folder" ? (
isExpanded ? (
<FolderOpenIcon className="h-4 w-4 text-blue-500" />
) : (
<FolderIcon className="h-4 w-4 text-blue-500" />
)
) : (
<DocumentIcon className="h-4 w-4 text-neutral-text-secondary" />
)}
</div>
{/* Name */}
<span
className={`text-neutral-text whitespace-nowrap ${node.type === "folder" ? "font-medium" : ""} pr-2`}
>
{node.name}
</span>
</div>
{/* Children */}
{node.type === "folder" &&
isExpanded &&
node.children.map((child) => (
<FileTreeItem
key={child.id}
node={child}
expandedFolders={expandedFolders}
toggleFolder={toggleFolder}
/>
))}
</div>
);
};
export const FileStructure = ({
fileStructure,
caption,
}: FileStructureProps) => {
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set(fileStructure?.map((item) => item.id) ?? [])
);
const toggleFolder = (id: string) => {
setExpandedFolders((prev) =>
prev.has(id)
? new Set([...prev].filter((item) => item !== id))
: new Set(prev).add(id)
);
};
if (!fileStructure?.length) return null;
const treeNodes = buildTree(fileStructure);
return (
<div className="my-8">
<div className="bg-background-brand-code border border-neutral-border rounded-xl overflow-hidden shadow-md">
{/* File Tree */}
<div className="p-4 font-mono text-sm overflow-x-scroll mr-4">
{treeNodes.map((node) => (
<FileTreeItem
key={node.id}
node={node}
expandedFolders={expandedFolders}
toggleFolder={toggleFolder}
/>
))}
</div>
</div>
{caption && (
<div className="font-tuner text-sm text-neutral-text-secondary mt-2">
Figure: {caption}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,171 @@
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { MdContentCopy } from "react-icons/md";
import { shikiSingleton } from "../standard-elements/code-block/shiki-singleton";
import "../standard-elements/code-block/code-block.css";
const CodeTab = ({
lang,
onCopy,
tooltipVisible,
}: {
lang?: string;
onCopy: () => void;
tooltipVisible: boolean;
}) => (
<div className="flex items-center justify-between w-full border-b border-neutral-border-subtle h-full">
<span className="justify-center relative leading-tight text-neutral-text py-[8px] text-base transition duration-150 ease-out rounded-t-3xl flex items-center gap-1 whitespace-nowrap px-6 font-medium">
{lang || "Unknown"}
</span>
<div className="relative ml-4 flex items-center space-x-4 overflow-visible pr-2">
<button
type="button"
onClick={onCopy}
className={`flex items-center text-sm font-medium text-neutral-text-secondary transition-colors duration-200 px-2 py-1 rounded hover:bg-white/10 cursor-pointer ${
tooltipVisible ? "ml-1 rounded-md" : ""
}`}
>
{!tooltipVisible && <MdContentCopy className="size-4" />}
<span>{!tooltipVisible ? "" : "Copied!"}</span>
</button>
</div>
</div>
);
interface CodeBlockProps {
value?: string;
lang?: string;
children?: React.ReactNode;
highlightLines: string;
}
const CodeBlockWithHighlightLines = ({
value,
lang = "javascript",
children,
highlightLines,
}: CodeBlockProps) => {
const [tooltipVisible, setTooltipVisible] = useState(false);
const [html, setHtml] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [mounted, setMounted] = useState(false);
const { resolvedTheme } = useTheme();
// Use a default theme for server-side rendering to prevent hydration mismatch
const isDarkMode = mounted ? resolvedTheme === "dark" : false;
const codeToHighlight = typeof children === "string" ? children : value || "";
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
if (!codeToHighlight || typeof codeToHighlight !== "string") {
if (isMounted) {
setHtml("");
setIsLoading(false);
}
return;
}
try {
// Add focus notation to the code based on highlightLines
let codeWithFocus = codeToHighlight;
if (highlightLines) {
const lines = codeToHighlight.split("\n");
const highlightRanges = highlightLines
.split(",")
.map((range) => range.trim())
.filter((range) => range);
// Process ranges in reverse order to avoid index shifting when inserting
const sortedRanges = highlightRanges
.map((range) => {
if (range.includes("-")) {
const [start, end] = range.split("-").map(Number);
return { start: start - 1, count: end - start + 1 };
}
const lineNum = Number.parseInt(range, 10) - 1;
return { start: lineNum, count: 1 };
})
.filter((r) => r.start >= 0 && r.start < lines.length)
.sort((a, b) => b.start - a.start); // Sort in reverse order
for (const { start, count } of sortedRanges) {
// Insert the focus notation comment before the target line
lines.splice(start, 0, `// [!code focus:${count}]`);
}
codeWithFocus = lines.join("\n");
}
const code = await shikiSingleton.codeToHtml(
codeWithFocus,
lang,
isDarkMode
);
if (isMounted) {
setHtml(code);
setIsLoading(false);
}
} catch (error) {
if (isMounted) {
setHtml(`<pre><code>${codeToHighlight}</code></pre>`);
setIsLoading(false);
}
}
};
load();
return () => {
isMounted = false;
};
}, [codeToHighlight, lang, isDarkMode, highlightLines]);
const copyToClipboard = () => {
navigator.clipboard.writeText(codeToHighlight).then(
() => {
setTooltipVisible(true);
setTimeout(() => setTooltipVisible(false), 1500);
},
(err) => {}
);
};
const shikiClassName = `shiki ${isDarkMode ? "night-owl" : "github-light"} ${
highlightLines ? "has-focused" : ""
}`;
return (
<div className="codeblock-container h-full flex flex-col">
<div className="sticky top-0 z-30">
<CodeTab
lang={lang}
onCopy={copyToClipboard}
tooltipVisible={tooltipVisible}
/>
</div>
<div
className={`${shikiClassName} w-full flex-1 overflow-x-auto bg-background-brand-code py-5 px-2 text-sm border border-neutral-border-subtle/50 shadow-sm rounded-b-xl lg:rounded-bl-none md:rounded-br-xl`}
style={{
overflowX: "hidden",
maxWidth: "100%",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
// biome-ignore lint/security/noDangerouslySetInnerHtml: Shiki output is trusted and already escaped for XSS safety.
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
);
};
export default CodeBlockWithHighlightLines;

View File

@@ -0,0 +1,355 @@
import { ChevronDownIcon } from "@heroicons/react/24/outline";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { TinaMarkdown } from "tinacms/dist/rich-text";
import CodeBlockWithHighlightLines from "./recipe.helpers";
// Skeleton components
const CodeBlockSkeleton = () => (
<div className="codeblock-container h-full flex flex-col">
<div className="sticky top-0 z-30">
<div className="flex items-center justify-between w-full border-b border-neutral-border-subtle h-full">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-24 mx-6" />
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-16 mr-2" />
</div>
</div>
<div className="w-full flex-1 bg-background-brand-code py-5 px-2 text-sm border border-neutral-border-subtle/50 shadow-sm rounded-b-xl lg:rounded-bl-none md:rounded-br-xl h-full">
<div className="space-y-3">
{[...Array(2)].map((_, i) => (
<div key={i} className="flex items-center space-x-4">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-8" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse flex-1" />
</div>
))}
</div>
</div>
</div>
);
const InstructionsSkeleton = () => (
<div>
{[...Array(2)].map((_, i) => (
<div
key={i}
className="bg-gray-800 p-4 border border-neutral-border-subtle border-x-0 first:border-t-0 last:border-b-0"
>
<div className="h-4 bg-gray-600 rounded animate-pulse w-1/2" />
</div>
))}
</div>
);
const MIN_INSTRUCTIONS_HEIGHT = 60;
export const RecipeBlock = (data: {
title?: string;
description?: string;
codeblock?: any;
code?: string;
instruction?: any;
}) => {
const { title, description, codeblock, code, instruction } = data;
const [highlightLines, setHighlightLines] = useState("");
const [clickedInstruction, setClickedInstruction] = useState<number | null>(
null
);
const [codeHeight, setCodeHeight] = useState<number | null>(null);
const [isBottomOfInstructions, setIsBottomOfInstructions] =
useState<boolean>(false);
const [isLoading, setIsLoading] = useState(true);
const [isMobile, setIsMobile] = useState(false);
const codeblockRef = useRef<HTMLDivElement>(null);
const codeContentRef = useRef<HTMLDivElement>(null);
const instructionBlockRefs = useRef<HTMLDivElement>(null);
const instructionRefs = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
// Set initial height after a delay to ensure content is rendered
const timer = setTimeout(() => {
if (codeContentRef.current) {
const height = Math.round(codeContentRef.current.offsetHeight);
setCodeHeight(height);
setIsLoading(false);
}
}, 200);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < 1024);
};
// Set initial value
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
// Monitor code content height changes
useEffect(() => {
if (!codeContentRef.current) return;
let timeoutId: NodeJS.Timeout;
let lastHeight = 0;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const newHeight = entry.contentRect.height;
// Only update if height changed significantly (more than 10px)
if (Math.abs(newHeight - lastHeight) > 10) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
setCodeHeight(Math.round(newHeight));
lastHeight = newHeight;
}, 100);
}
}
});
resizeObserver.observe(codeContentRef.current);
return () => {
clearTimeout(timeoutId);
resizeObserver.disconnect();
};
}, []);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const isInsideInstructions = target.closest(".instructions");
if (!isInsideInstructions && clickedInstruction !== null) {
setClickedInstruction(null);
setHighlightLines("");
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [clickedInstruction]);
const checkIfBottom = (event: React.UIEvent<HTMLDivElement>) => {
const { scrollHeight, scrollTop, clientHeight } = event.currentTarget;
setIsBottomOfInstructions(scrollHeight - scrollTop <= clientHeight + 10);
};
const handleInstructionClick = (
index: number,
codeLineStart?: number,
codeLineEnd?: number
) => {
setHighlightLines(`${codeLineStart}-${codeLineEnd}`);
setClickedInstruction(index === clickedInstruction ? null : index);
const linePixelheight = 24;
// gives the moving logic some breathing room
const linePixelBuffer = 15;
if (codeblockRef.current) {
codeblockRef.current.scrollTo({
top: linePixelheight * (codeLineStart || 0) - linePixelBuffer,
behavior: "smooth",
});
}
// On mobile, scroll to instruction
if (isMobile && instructionRefs.current[index]) {
instructionRefs.current[index].scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
};
const handleDownArrowClick = () => {
const lastInstruction =
instructionRefs.current[instructionRefs.current.length - 1];
if (lastInstruction) {
lastInstruction.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
};
const calculateInstructionsHeight = () => {
return instructionRefs.current.reduce((total, ref) => {
return total + (ref?.offsetHeight || 0);
}, 0);
};
const getInstructionsHeight = () => {
// Mobile: Use reasonable max height
if (isMobile) {
return "300px"; // Fixed reasonable height for mobile
}
// Desktop: Match code height but with minimum
if (codeHeight && codeHeight > MIN_INSTRUCTIONS_HEIGHT) {
return `${codeHeight + 2}px`;
}
return "auto";
};
const checkIfScrollable = () => {
const instructionsHeight = calculateInstructionsHeight();
if (isMobile) {
return instructionsHeight >= 300; // Mobile fixed height
}
return instructionsHeight > (codeHeight || 0);
};
return (
<div className="recipe-block-container relative w-full">
<div className="title-description">
{title && (
<h2 className="text-2xl font-medium brand-primary-gradient mb-2 font-heading">
{title}
</h2>
)}
{description && (
<p className="text-neutral-text font-light mb-5 font-body">
{description}
</p>
)}
</div>
<div className="content-wrapper flex flex-col lg:flex-row rounded-2xl overflow-hidden border border-neutral-border shadow-md">
<div
className="instructions relative flex shrink-0 flex-col bg-neutral-background-secondary lg:w-1/3 lg:border-r lg:border-b-0 border-b border-neutral-border"
ref={instructionBlockRefs}
style={{
height: getInstructionsHeight(),
maxHeight: isMobile ? "300px" : "none",
}}
>
<div
className={`${
isBottomOfInstructions ||
instruction?.length === 0 ||
!instruction ||
!checkIfScrollable()
? "opacity-0"
: ""
} absolute bottom-0 left-0 right-0 z-10 transition-all duration-300 pointer-events-none`}
>
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-black to-transparent opacity-60" />
<ChevronDownIcon className="absolute bottom-4 left-1/2 size-7 -translate-x-1/2 text-xl text-white" />
</div>
<div className="flex-1 overflow-auto" onScroll={checkIfBottom}>
<div
className={`transition-opacity duration-300 ${
isLoading ? "opacity-100" : "opacity-0"
}`}
>
{isLoading && <InstructionsSkeleton />}
</div>
<div
className={`transition-opacity duration-300 ${
isLoading ? "opacity-0" : "opacity-100"
}`}
>
{!isLoading &&
(instruction?.map((inst, idx) => (
<div
key={`instruction-${idx}`}
ref={(element) => {
instructionRefs.current[idx] = element;
}}
className={`instruction-item cursor-pointer bg-neutral-background-secondary p-4 text-neutral-text border border-neutral-border-subtle border-x-0 first:border-t-0 last:border-b-0 last:rounded-bl-none
${clickedInstruction === idx ? "bg-brand-primary-contrast" : ""}`}
onClick={(e) => {
e.stopPropagation();
handleInstructionClick(
idx,
inst.codeLineStart,
inst.codeLineEnd
);
}}
>
<h5 className="font-light">{`${idx + 1}. ${
inst.header || "Default Header"
}`}</h5>
<div
className={`overflow-auto transition-all ease-in-out ${
clickedInstruction === idx
? "max-h-full opacity-100 duration-500"
: "max-h-0 opacity-0 duration-0"
}`}
>
<p className="mt-2 text-sm text-neutral-text-secondary leading-relaxed">
{inst.itemDescription || "Default Item Description"}
</p>
</div>
</div>
)) || (
<p className="p-4 text-neutral-text-secondary py-4">
No instructions available.
</p>
))}
</div>
</div>
</div>
<div
ref={codeblockRef}
className="flex flex-col z-10 h-full lg:w-2/3 py-0 bg-neutral-background overflow-auto"
>
<div ref={codeContentRef}>
<div
className={`transition-opacity duration-300 ${
isLoading ? "opacity-100" : "opacity-0"
}`}
>
{isLoading && <CodeBlockSkeleton />}
</div>
<div
className={`transition-opacity duration-300 ${
isLoading ? "opacity-0" : "opacity-100"
}`}
>
{!isLoading &&
(code ? (
<CodeBlockWithHighlightLines
value={code.replaceAll("<22>", " ")}
lang="javascript"
highlightLines={highlightLines}
/>
) : codeblock ? (
<TinaMarkdown
content={codeblock}
components={{
code_block: (props) => (
<CodeBlockWithHighlightLines
{...props}
highlightLines={highlightLines}
/>
),
}}
/>
) : (
<p className="p-4">No code block available.</p>
))}
</div>
</div>
</div>
</div>
</div>
);
};
export default RecipeBlock;

View File

@@ -0,0 +1,115 @@
import type React from "react";
import { useEffect, useState } from "react";
export interface Item {
id?: string;
offset?: number;
level?: string;
src?: string;
}
/** UseWindowSize Hook */
export function useWindowSize() {
const [windowSize, setWindowSize] = useState<{
width: number;
height: number;
}>({ width: 1200, height: 800 });
useEffect(() => {
if (typeof window === "undefined") return;
const handleResize = () => {
setWindowSize({ width: window.innerWidth, height: window.innerHeight });
};
// Set initial size
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return windowSize;
}
/** Throttled scroll listener */
export function createListener(
componentRef: React.RefObject<HTMLDivElement>,
headings: Item[],
// Callback to update active IDs - param name in type is just for documentation
setActiveIds: (activeIds: string[]) => void
) {
let tick = false;
const THROTTLE_INTERVAL = 100;
const maxScrollY = document.documentElement.scrollHeight - window.innerHeight;
const maxScrollYRelative =
(maxScrollY - componentRef.current.offsetTop) /
componentRef.current.scrollHeight;
const relativePositionHeadingMap = headings.map((heading) => {
const relativePosition =
1 -
(componentRef.current.scrollHeight - (heading.offset || 0)) /
componentRef.current.scrollHeight;
return {
...heading,
relativePagePosition:
maxScrollYRelative > 1
? relativePosition
: relativePosition * maxScrollYRelative,
};
});
const throttledScroll = () => {
if (!componentRef.current) return;
const scrollPos =
window.scrollY - componentRef.current.offsetTop + window.innerHeight / 6;
const newActiveIds: string[] = [];
const relativeScrollPosition =
scrollPos / componentRef.current.scrollHeight;
const activeHeadingCandidates = relativePositionHeadingMap.filter(
(heading) => relativeScrollPosition >= heading.relativePagePosition
);
const activeHeading =
activeHeadingCandidates.length > 0
? activeHeadingCandidates.reduce((prev, current) =>
(prev.offset || 0) > (current.offset || 0) ? prev : current
)
: (headings[0] ?? {});
newActiveIds.push(activeHeading.id || "");
if (activeHeading.level !== "H2") {
const activeHeadingParentCandidates =
activeHeadingCandidates.length > 0
? activeHeadingCandidates.filter((h) => h.level === "H2")
: [];
const activeHeadingParent =
activeHeadingParentCandidates.length > 0
? activeHeadingParentCandidates.reduce((prev, current) =>
(prev.offset || 0) > (current.offset || 0) ? prev : current
)
: null;
if (activeHeadingParent?.id) {
newActiveIds.push(activeHeadingParent.id);
}
}
setActiveIds(newActiveIds);
};
return function onScroll() {
if (!tick) {
setTimeout(() => {
throttledScroll();
tick = false;
}, THROTTLE_INTERVAL);
}
tick = true;
};
}

View File

@@ -0,0 +1,202 @@
import Image from "next/image";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { TinaMarkdown } from "tinacms/dist/rich-text";
import {
type Item,
createListener,
useWindowSize,
} from "./scroll-showcase.helpers";
/** Main Component */
export function ScrollBasedShowcase(data: {
showcaseItems: {
title: string;
image: string;
content: any;
useAsSubsection?: boolean;
}[];
}) {
const [headings, setHeadings] = useState<Item[]>([]);
const componentRef = useRef<HTMLDivElement>(null);
const headingRefs = useRef<(HTMLHeadingElement | null)[]>([]);
const [activeIds, setActiveIds] = useState<string[]>([]);
const [activeImageSrc, setActiveImageSrc] = useState<string>("");
const windowSize = useWindowSize();
/** Build headings array on mount */
useEffect(() => {
const tempHeadings: Item[] = [];
data.showcaseItems?.forEach((item, index) => {
const headingData: Item = {
id: `${item.title}-${index}`,
level: item.useAsSubsection ? "H3" : "H2",
src: item.image,
offset: headingRefs.current[index]?.offsetTop ?? 0,
};
tempHeadings.push(headingData);
});
setHeadings(tempHeadings);
// Set initial active image
if (tempHeadings.length > 0 && tempHeadings[0].src) {
setActiveImageSrc(tempHeadings[0].src);
}
}, [data.showcaseItems]);
/** Update heading offsets on resize */
useEffect(() => {
const updateOffsets = () => {
const updatedHeadings = headings.map((heading, index) => ({
...heading,
offset: headingRefs.current[index]?.offsetTop ?? 0,
}));
setHeadings(updatedHeadings);
};
window.addEventListener("resize", updateOffsets);
return () => window.removeEventListener("resize", updateOffsets);
}, [headings]);
/** Throttled scroll event */
useEffect(() => {
if (typeof window === "undefined" || !componentRef.current) return;
const activeTocListener = createListener(
componentRef as React.RefObject<HTMLDivElement>,
headings,
setActiveIds
);
window.addEventListener("scroll", activeTocListener);
return () => window.removeEventListener("scroll", activeTocListener);
}, [headings]);
/** Update active image when activeIds change */
useEffect(() => {
if (!activeIds.length) return;
const heading = headings.find((h) => h.id === activeIds[0]);
if (heading?.src) {
setActiveImageSrc(heading.src);
}
}, [activeIds, headings]);
return (
<div
ref={componentRef}
// doc-container replacements:
className="relative mx-auto my-5 block w-full"
>
<div className="relative flex ">
<div
id="main-content-container"
className="m-2 box-border flex min-h-full gap-20 flex-1 flex-col justify-between px-2 pb-16 pt-8"
>
{data.showcaseItems?.map((item, index) => {
const itemId = `${item.title}-${index}`;
const isFocused = activeIds.includes(itemId);
return (
<div
key={`showcase-item-${index}`}
// If active => full opacity + orange border + text colors
// If not => half opacity + gray border
className={`mt-0 transition-all duration-300 ease-in-out md:mt-8
${
isFocused
? "text-neutral-text opacity-100"
: "border-neutral-border text-neutral-text-secondary opacity-15"
}
`}
>
{item.useAsSubsection ? (
<div
id={itemId}
className="pointer-events-none"
ref={(element) => {
headingRefs.current[index] = element;
}}
>
<div
className={`my-2 bg-gradient-to-br bg-clip-text text-xl font-medium text-transparent ${
isFocused
? "from-orange-400 via-orange-500 to-orange-600"
: "from-gray-800 to-gray-700"
} !important`}
>
{item.title}
</div>
</div>
) : (
<div
id={itemId}
className="pointer-events-none"
ref={(element) => {
headingRefs.current[index] = element;
}}
>
<h2
className={`mb-3 mt-4 text-3xl ${
isFocused
? "brand-primary-gradient"
: "text-neutral-text-secondary"
}`}
>
{item.title}
</h2>
</div>
)}
<ul
className={`list-none border-l-4 pl-4 transition-colors duration-500 ease-in-out ${
isFocused
? "border-brand-primary"
: "border-neutral-text-secondary"
}`}
>
<li>
<TinaMarkdown content={item.content} />
</li>
</ul>
{/* This image is only shown on mobile (md:hidden).
On larger screens, the separate container is used. */}
{item.image && (
<Image
src={`${process.env.NEXT_PUBLIC_BASE_PATH || ""}${
item.image
}`}
alt={item.title}
width={500}
height={300}
className="my-8 block md:hidden"
/>
)}
</div>
);
})}
</div>
{/* This image container is only displayed on md+ */}
<div className="relative hidden w-full flex-1 overflow-hidden md:block">
{activeImageSrc && (
<Image
src={`${
process.env.NEXT_PUBLIC_BASE_PATH || ""
}${activeImageSrc}`}
alt=""
width={500}
height={300}
className="w-100 absolute right-0 rounded-lg transition-all duration-1000 ease-in-out"
style={{
opacity: activeIds.length ? 1 : 0,
top:
(headings.find((h) => h.id && activeIds.includes(h.id))
?.offset || 0) + 100,
transform: "translateY(-50%)",
}}
/>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { tinaField } from "tinacms/dist/react";
import { TinaMarkdown } from "tinacms/dist/rich-text";
import MarkdownComponentMapping from "../markdown-component-mapping";
export default function TypeDefinition(props) {
{
const propertyItem = (property) => {
return (
<div className="space-y-4 py-2 px-6">
<div className="flex flex-col md:flex-row md:items-start gap-2">
<div className="w-full md:w-1/3">
<div
className="font-heading text-lg text-neutral-text break-normal max-w-full inline-block"
data-tina-field={tinaField(property, "name")}
>
{property?.name?.replace(/([A-Z])/g, "\u200B$1")}
</div>
<div className="mb-1">
{property.required && (
<p className="text-amber-600 font-medium text-xs">REQUIRED</p>
)}
{property.experimental && (
<p className="bg-gradient-to-r from-brand-secondary-gradient-start to-brand-secondary-gradient-end bg-clip-text text-transparent font-medium text-xs">
EXPERIMENTAL
</p>
)}
</div>
</div>
<div className="w-full md:w-2/3">
<div
data-tina-field={tinaField(property, "typeUrl")}
className={`w-fit text-sm mb-0.5 ${property.typeUrl ? "underline decoration-neutral-text hover:decoration-neutral-text/20 text-neutral-text hover:text-neutral-text/50" : "text-neutral-text"}`}
>
{property.typeUrl ? (
<a href={property.typeUrl} rel="noopener noreferrer">
{property.type}
</a>
) : (
property.type
)}
</div>
<div
className="text-neutral-text-secondary text-sm w-fit"
data-tina-field={tinaField(property, "description")}
>
<TinaMarkdown
content={property.description}
components={MarkdownComponentMapping}
/>
</div>
</div>
</div>
</div>
);
};
return (
<div className="bg-neutral-background rounded-lg shadow-lg my-6 py-2 border-neutral-border border">
{props.property?.map((property, index) => (
<div key={`property-${index}`}>
{index !== 0 && (
<hr className="h-0.25 w-full bg-neutral-border rounded-lg border-none" />
)}
{propertyItem(property)}
</div>
))}
{props.property?.some((property) => property.required) && (
<div className=" mx-6 my-2 p-4 bg-neutral-background-secondary border-neutral-border border rounded-md flex items-start gap-3">
<p className="text-sm text-neutral-text">
All properties marked as{" "}
<span className="text-amber-600 font-medium">REQUIRED</span> must
be specified for the field to work properly.
</p>
</div>
)}
</div>
);
}
}

View File

@@ -0,0 +1,33 @@
import { tinaField } from "tinacms/dist/react";
export default function Youtube(data: {
embedSrc: string;
caption?: string;
minutes?: string;
}) {
const { embedSrc, caption, minutes } = data;
return (
<div className="my-6 flex flex-col gap-2">
<div
className="relative aspect-video w-full"
data-tina-field={tinaField(data, "embedSrc")}
>
<iframe
className="absolute left-0 top-0 size-full rounded-xl"
src={embedSrc}
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
/>
</div>
{caption && (
<div
className="font-tuner text-sm text-neutral-text-secondary"
data-tina-field={tinaField(data, "caption")}
>
Video: {caption} {minutes && `(${minutes} minutes)`}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,180 @@
import type { Components, TinaMarkdownContent } from "tinacms/dist/rich-text";
import Accordion, { AccordionBlock } from "./embedded-elements/accordion";
import { ApiReference } from "./embedded-elements/api-reference";
import Callout from "./embedded-elements/callout";
import { CardGrid } from "./embedded-elements/card-grid";
import { CodeTabs } from "./embedded-elements/code-tabs";
import { FileStructure } from "./embedded-elements/file-structure";
import RecipeBlock from "./embedded-elements/recipe";
import { ScrollBasedShowcase } from "./embedded-elements/scroll-showcase";
import TypeDefinition from "./embedded-elements/type-definition";
import Youtube from "./embedded-elements/youtube";
import Blockquote from "./standard-elements/blockquote";
import { CodeBlock } from "./standard-elements/code-block/code-block";
import HeaderFormat from "./standard-elements/header-format";
import { ImageComponent } from "./standard-elements/image";
import MermaidElement from "./standard-elements/mermaid-diagram";
import Table from "./standard-elements/table";
type ComponentMapping = {
youtube: { embedSrc: string; caption?: string; minutes?: string };
codeTabs: {
tabs: {
name: string;
content: string;
id?: string;
}[];
initialSelectedIndex?: number;
};
typeDefinition: {
property: {
name: string;
description: TinaMarkdownContent;
type: string;
typeUrl: string;
required: boolean;
}[];
};
blockquote: {
children: {
props: {
content: TinaMarkdownContent;
};
};
};
apiReference: {
title: string;
property: {
groupName: string;
name: string;
description: string;
type: string;
default: string;
required: boolean;
}[];
};
Callout: { body: TinaMarkdownContent; variant: string };
accordion: {
docText: string;
image: string;
heading?: string;
fullWidth?: boolean;
};
recipe: {
title?: string;
description?: string;
codeblock?: any;
instruction?: {
header?: string;
itemDescription?: string;
codeLineStart?: number;
codeLineEnd?: number;
}[];
};
scrollShowcase: {
showcaseItems: {
image: string;
title: string;
useAsSubsection: boolean;
content: string;
}[];
};
cardGrid: {
cards: {
title: string;
description: string;
link: string;
linkText: string;
}[];
};
code_block: {
value: string;
lang: string;
children: string;
};
accordionBlock: {
accordionItems: {
docText: string;
image: string;
heading?: string;
fullWidth?: boolean;
}[];
};
fileStructure: {
fileStructure: {
id: string;
name: string;
type: "file" | "folder";
parentId: string | null;
}[];
};
};
type CalloutVariant =
| "warning"
| "info"
| "success"
| "error"
| "idea"
| "lock"
| "api";
export const MarkdownComponentMapping: Components<ComponentMapping> = {
// Our embeds we can inject via MDX
scrollShowcase: (props) => <ScrollBasedShowcase {...props} />,
cardGrid: (props) => <CardGrid {...props} />,
recipe: (props) => <RecipeBlock {...props} />,
accordion: (props) => <Accordion {...props} />,
apiReference: (props) => <ApiReference {...props} />,
youtube: (props) => <Youtube {...props} />,
codeTabs: (props) => <CodeTabs {...props} />,
Callout: (props: { body: TinaMarkdownContent; variant: string }) => (
<Callout {...props} variant={props.variant as CalloutVariant} />
),
// Our default markdown components
h1: (props) => <HeaderFormat level={1} {...props} />,
h2: (props) => <HeaderFormat level={2} {...props} />,
h3: (props) => <HeaderFormat level={3} {...props} />,
h4: (props) => <HeaderFormat level={4} {...props} />,
h5: (props) => <HeaderFormat level={5} {...props} />,
h6: (props) => <HeaderFormat level={6} {...props} />,
ul: (props) => (
<ul className="my-4 ml-2 list-disc text-neutral-text" {...props} />
),
hr: (props) => (
<hr className="w-[50%] h-0.25 bg-neutral-text-secondary text-transparent ml-4 my-8" />
),
ol: (props) => (
<ol className="my-4 ml-2 list-decimal text-neutral-text" {...props} />
),
li: (props) => <li className="mb-2 ml-8 " {...props} />,
p: (props) => <p className="my-2.5 text-neutral-text" {...props} />,
blockquote: (props) => <Blockquote {...props} />,
a: (props) => (
<a
href={props?.url}
{...props}
className="underline opacity-80 transition-all duration-200 ease-out hover:text-brand-primary text-neutral-text"
/>
),
code: (props) => (
<code
className="rounded border-y-neutral-border bg-neutral-surface shadow-sm px-1 py-0.5 text-brand-primary border border-neutral-border"
{...props}
/>
),
img: (props) => <ImageComponent {...props} />,
table: (props) => <Table {...props} />,
code_block: (props) =>
props?.lang === "mermaid" ? (
<MermaidElement {...props} />
) : (
<CodeBlock {...props} />
),
accordionBlock: (props) => <AccordionBlock {...props} />,
typeDefinition: (props) => <TypeDefinition {...props} />,
fileStructure: (props) => <FileStructure {...props} />,
};
export default MarkdownComponentMapping;

View File

@@ -0,0 +1,20 @@
import { TinaMarkdown, type TinaMarkdownContent } from "tinacms/dist/rich-text";
import MarkdownComponentMapping from "../markdown-component-mapping";
const Blockquote = (props) => {
return (
<blockquote className="relative overflow-hidden rounded-lg bg-neutral-background-secondary border-l-4 my-4 shadow-sm border-neutral-border">
<div className="flex items-start gap-3 px-4">
<div
className={`leading-6 text-neutral-text font-light py-2 ${
props.text ? "my-2.5" : ""
}`}
>
{props.children}
</div>
</div>
</blockquote>
);
};
export default Blockquote;

View File

@@ -0,0 +1,78 @@
import React from "react";
export const CodeBlockSkeleton = ({ hasTabs = false }) => {
// Use deterministic values to prevent hydration issues
const skeletonLines = React.useMemo(() => {
const lines: Array<{ width: string; delay: string }> = [];
const widths = ["35%", "45%", "50%", "60%", "65%", "75%", "80%"];
const numberOfLines = 6; // Fixed number to prevent hydration issues
for (let i = 0; i < numberOfLines; i++) {
const widthIndex = i % widths.length;
lines.push({
width: widths[widthIndex],
delay: `${i * 0.1}s`,
});
}
return lines;
}, []);
const secondaryLines = [
{ width: "30%", delay: "0.05s" },
{ width: "25%", delay: "0.15s" },
{ width: "35%", delay: "0.25s" },
{ width: "20%", delay: "0.35s" },
];
return (
<div className={`relative w-full ${hasTabs ? "" : "my-2"}`}>
{!hasTabs && <InlineCopyButton />}
<div
className={`shiki w-full overflow-x-auto bg-background-brand-code py-4 px-2 text-sm shadow-sm rounded-lg ${
hasTabs ? "" : "border border-neutral-border-subtle"
}`}
>
<div className="space-y-2">
{skeletonLines.map((line, index) => (
<div key={index} className="flex items-center space-x-4">
<div className="w-8 h-4 bg-neutral-border-subtle rounded animate-pulse flex-shrink-0" />
<div className="flex-1 space-y-1">
<div
className="h-4 bg-neutral-border-subtle rounded animate-pulse"
style={{
width: line.width,
animationDelay: line.delay,
}}
/>
{index % 3 === 0 && (
<div
className="h-4 bg-neutral-border-subtle rounded animate-pulse"
style={{
width:
secondaryLines[
Math.floor(index / 3) % secondaryLines.length
]?.width || "25%",
animationDelay:
secondaryLines[
Math.floor(index / 3) % secondaryLines.length
]?.delay || "0.05s",
}}
/>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
};
const InlineCopyButton = () => {
return (
<div className="absolute top-0 right-0 z-10 px-4 py-1 text-xs font-mono text-neutral-text-secondary">
<div className="w-8 h-3 bg-neutral-border-subtle rounded animate-pulse" />
</div>
);
};

View File

@@ -0,0 +1,79 @@
pre.shiki {
counter-reset: line;
}
.shiki .diff.add {
background-color: rgba(22, 163, 77, 0.3);
padding-top: 0.1rem;
padding-bottom: 0.1rem;
}
:root.dark .shiki .diff.add {
background-color: rgba(22, 163, 77, 0.3);
padding-top: 0.1rem;
padding-bottom: 0.1rem;
}
.shiki .diff.remove {
background-color: rgba(220, 57, 38, 0.25);
padding-top: 0.1rem;
padding-bottom: 0.1rem;
}
:root.dark .shiki .diff.remove {
background-color: rgba(220, 57, 38, 0.25);
padding-top: 0.1rem;
padding-bottom: 0.1rem;
}
.shiki .line.diff.add::before {
color: #14532d;
}
.shiki .line.diff.remove::before {
color: #7f1d1d;
}
:root.dark .shiki .line.diff.add::before {
color: #6ff4b3;
}
:root.dark .shiki .line.diff.remove::before {
color: #f43a3a;
}
.shiki .line::before {
counter-increment: line;
content: counter(line);
display: inline-block;
width: 2em;
margin-right: 1em;
text-align: right;
color: var(--neutral-text-secondary);
}
.shiki .line.highlighted {
background-color: rgba(203, 213, 255, 0.6);
padding-top: 0.1rem;
padding-bottom: 0.1rem;
}
:root.dark .shiki .line.highlighted {
background-color: rgb(100, 107, 139, 0.6);
padding-top: 0.1rem;
padding-bottom: 0.1rem;
}
.shiki.has-focused .line:not(.focused) {
opacity: 0.4;
filter: grayscale(0.3);
transition: opacity 0.2s, filter 0.2s;
}
.shiki span.line {
margin-right: 1rem;
}
.shiki {
overflow-x: auto !important;
}

View File

@@ -0,0 +1,113 @@
import { useEffect, useState } from "react";
import "./code-block.css";
import { useTheme } from "next-themes";
import { FaCheck } from "react-icons/fa";
import { MdOutlineContentCopy } from "react-icons/md";
import { CodeBlockSkeleton } from "./code-block-skeleton";
import { shikiSingleton } from "./shiki-singleton";
export function CodeBlock({
value,
lang = "ts",
showCopyButton = true,
showBorder = true,
setIsTransitioning,
}: {
value: string;
lang?: string;
showCopyButton?: boolean;
showBorder?: boolean;
setIsTransitioning?: (isTransitioning: boolean) => void;
}) {
const [html, setHtml] = useState("");
const [isCopied, setIsCopied] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const { resolvedTheme } = useTheme();
const isDarkMode = resolvedTheme === "dark";
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
// Guard clause to prevent processing undefined/null/empty values - shiki will throw an error if the value is not a string as it tries to .split all values
if (!value || typeof value !== "string") {
if (isMounted) {
setHtml("");
setIsLoading(false);
}
return;
}
try {
const code = await shikiSingleton.codeToHtml(value, lang, isDarkMode);
if (isMounted) {
setHtml(code);
setIsLoading(false);
}
} catch (error) {
if (isMounted) {
// Fallback to plain text if highlighting fails
setHtml(`<pre><code>${value}</code></pre>`);
setIsLoading(false);
}
}
};
load();
return () => {
isMounted = false;
};
}, [value, lang, isDarkMode]);
useEffect(() => {
if (setIsTransitioning && html !== "") {
// Increase timeout to 200ms for smoother transitions, especially on slower devices.
setTimeout(() => setIsTransitioning(false), 200);
}
}, [html, setIsTransitioning]);
// Show skeleton while loading
if (isLoading && showCopyButton) {
return <CodeBlockSkeleton />;
}
return (
<div className={`relative w-full my-2 ${showCopyButton ? " group" : ""}`}>
<div
className={`absolute top-0 right-0 z-10 px-4 py-1 text-xs font-mono text-neutral-text-secondary transition-opacity duration-200 opacity-100 group-hover:opacity-0 group-hover:pointer-events-none ${
showCopyButton ? "" : "hidden"
}`}
>
{lang}
</div>
<div
className={`absolute top-0 right-0 z-10 mx-2 my-1 text-xs font-mono transition-opacity duration-200 opacity-0 group-hover:opacity-100 cursor-pointer ${
showCopyButton ? "" : "hidden"
}`}
>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(value);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 1000);
}}
className="px-2 py-1 text-neutral-text-secondary rounded transition cursor-pointer flex items-center gap-1"
>
{isCopied ? <FaCheck size={12} /> : <MdOutlineContentCopy />}
</button>
</div>
<div
className={`shiki w-full overflow-x-auto bg-background-brand-code py-5 px-2 text-sm ${
showBorder ? "border border-neutral-border-subtle/50 shadow-sm" : ""
} ${showCopyButton ? "rounded-lg" : "rounded-b-xl"}`}
// biome-ignore lint/security/noDangerouslySetInnerHtml: Shiki output is trusted and already escaped for XSS safety.
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
);
}

View File

@@ -0,0 +1,100 @@
import {
transformerNotationDiff,
transformerNotationFocus,
transformerNotationHighlight,
} from "@shikijs/transformers";
import { type Highlighter, createHighlighter } from "shiki";
class ShikiSingleton {
private highlighter: Highlighter | null = null;
private supportedLangs = new Set<string>();
private readonly themes = ["night-owl", "github-light"];
private initPromise: Promise<void> | null = null;
private async initializeHighlighter(initialLang: string): Promise<void> {
if (this.highlighter) return;
this.highlighter = await createHighlighter({
themes: this.themes,
langs: [initialLang],
});
this.supportedLangs.add(initialLang);
}
private async ensureLanguageLoaded(lang: string): Promise<void> {
if (!this.highlighter) {
throw new Error("Highlighter not initialized");
}
if (!this.supportedLangs.has(lang)) {
try {
await this.highlighter.loadLanguage(lang as any);
this.supportedLangs.add(lang);
} catch {
// Fallback to a default language if the requested one fails
if (!this.supportedLangs.has("text")) {
await this.highlighter.loadLanguage("text" as any);
this.supportedLangs.add("text");
}
}
}
}
async getHighlighter(lang: string): Promise<Highlighter> {
// If no highlighter exists, initialize it
if (!this.highlighter) {
// Prevent multiple simultaneous initializations
if (!this.initPromise) {
this.initPromise = this.initializeHighlighter(lang);
}
await this.initPromise;
}
// Ensure the required language is loaded
await this.ensureLanguageLoaded(lang);
if (!this.highlighter) {
throw new Error("Failed to initialize highlighter");
}
return this.highlighter;
}
async codeToHtml(
code: string,
lang: string,
isDarkMode: boolean
): Promise<string> {
const highlighter = await this.getHighlighter(lang);
return highlighter.codeToHtml(code, {
lang,
theme: isDarkMode ? "night-owl" : "github-light",
transformers: [
transformerNotationDiff({ matchAlgorithm: "v3" }),
transformerNotationHighlight({ matchAlgorithm: "v3" }),
transformerNotationFocus({ matchAlgorithm: "v3" }),
],
meta: {
showLineNumbers: true,
},
});
}
dispose(): void {
if (this.highlighter) {
this.highlighter.dispose();
this.highlighter = null;
this.supportedLangs.clear();
this.initPromise = null;
}
}
// Get info about loaded languages (useful for debugging)
getLoadedLanguages(): string[] {
return Array.from(this.supportedLangs);
}
}
// Export the singleton instance
export const shikiSingleton = new ShikiSingleton();

View File

@@ -0,0 +1,90 @@
import { formatHeaderId } from "@/utils/docs";
import { LinkIcon } from "@heroicons/react/24/outline";
import React, { useCallback, useEffect } from "react";
export default function HeaderFormat({
children,
level,
}: {
children?: React.ReactNode;
level: number;
}) {
const HeadingTag = `h${level}` as any;
const id = formatHeaderId(
React.isValidElement(children) && children.props?.content
? children.props.content.map((content: any) => content.text).join("")
: typeof children === "string"
? children
: ""
);
const linkHref = `#${id}`;
const styles = {
1: "text-brand-primary text-4xl !mt-16 mb-4 font-light",
2: "text-brand-primary text-3xl !mt-12 mb-2 font-light",
3: "text-brand-primary text-2xl !mt-8 mb-2 !important font-light",
4: "text-brand-primary text-xl !mt-8 mb-2 font-light",
5: "text-brand-primary text-lg !mt-2 mb-1 font-light",
6: "text-neutral-text-secondary text-base font-normal mt-2 mb-1",
};
const linkStyle = {
1: "text-brand-primary size-8",
2: "text-brand-primary size-6",
3: "text-brand-primary size-6",
4: "text-brand-primary size-6",
5: "text-brand-primary size-4",
6: "text-neutral-text-secondary size-4",
};
const handleHeaderClick = (event) => {
event.preventDefault();
scrollToElement(id);
window.history.pushState(null, "", linkHref);
};
const scrollToElement = useCallback((elementId) => {
const element = document.getElementById(elementId);
if (element) {
const offset = 130; //offset in pixels
const elementPosition =
element.getBoundingClientRect().top + window.scrollY;
const offsetPosition = elementPosition - offset;
window.scrollTo({
top: offsetPosition,
behavior: "smooth",
});
}
}, []);
useEffect(() => {
if (window.location.hash) {
const hash = window.location.hash.substring(1);
scrollToElement(hash);
}
//this is used for when you get sent a link with a hash (i.e link to a header)
}, [scrollToElement]);
return (
<HeadingTag
id={id}
className={`${styles[level]} group relative cursor-pointer`}
>
<a
href={linkHref}
className="inline-block no-underline"
onClick={handleHeaderClick}
>
{" "}
{children}
<LinkIcon
className={`${linkStyle[level]} group-hover:animate-wiggle absolute ml-1 opacity-0 transition-opacity duration-200 group-hover:opacity-80`}
style={{
display: "inline-block",
marginTop: "0.25rem",
}}
/>
</a>
</HeadingTag>
);
}

View File

@@ -0,0 +1,58 @@
import Image from "next/image";
import { useState } from "react";
import { ImageOverlayWrapper } from "../../ui/image-overlay-wrapper";
export const ImageComponent = (props) => {
const [dimensions, setDimensions] = useState({ width: 16, height: 9 });
const [isLoading, setIsLoading] = useState(true);
const handleImageLoad = (event) => {
const img = event.target as HTMLImageElement;
if (img) {
setDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
setIsLoading(false);
}
};
return (
<span className="my-4 flex flex-col gap-2">
<ImageOverlayWrapper
src={props?.url || ""}
alt={props?.alt || ""}
caption={props?.caption}
>
<span className="relative w-full max-w-xl block">
<span
className="relative overflow-hidden rounded-xl block"
style={{
aspectRatio: `${dimensions.width}/${dimensions.height}`,
maxHeight: "600px",
minHeight: "200px",
opacity: isLoading ? 0 : 1,
transition: "opacity 0.3s ease-in-out",
}}
>
<Image
src={props?.url || ""}
alt={props?.alt || ""}
title={props?.caption || ""}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 60vw"
style={{ objectFit: "contain" }}
onLoad={handleImageLoad}
priority
/>
</span>
</span>
</ImageOverlayWrapper>
{props?.caption && (
<span className="font-tuner text-sm text-neutral-text-secondary block text-center md:text-left">
Figure: {props.caption}
</span>
)}
</span>
);
};

View File

@@ -0,0 +1,73 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useRef, useState } from "react";
// Global flag to ensure mermaid is only initialized once
let mermaidInitialized = false;
function MermaidDiagramClient(data: { value?: string }) {
const { value } = data;
const mermaidRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
// Only mount on client
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted || !value || !mermaidRef.current) return;
const renderDiagram = async () => {
try {
// Dynamically import mermaid to ensure it's only loaded on client
const mermaid = (await import("mermaid")).default;
// Initialize mermaid only once globally
if (!mermaidInitialized) {
mermaid.initialize({
startOnLoad: false,
theme: "default",
securityLevel: "loose",
});
mermaidInitialized = true;
}
// Generate unique ID for this render
const diagramId = `mermaid-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
// Render the specific diagram with unique ID
const { svg } = await mermaid.render(diagramId, value);
// Insert the rendered SVG into the DOM
if (mermaidRef.current) {
mermaidRef.current.innerHTML = svg;
}
} catch (error) {
// Fallback to showing the raw text
if (mermaidRef.current) {
mermaidRef.current.innerHTML = `<pre>${value}</pre>`;
}
}
};
renderDiagram();
}, [mounted, value]);
return (
<div contentEditable={false}>
<div
ref={mermaidRef}
className="mermaid-container dark:bg-brand-primary-contrast w-fit rounded-md p-4"
>
<pre className="mermaid">{value}</pre>
</div>
</div>
);
}
// Export as dynamic component with no SSR
export default dynamic(() => Promise.resolve(MermaidDiagramClient), {
ssr: false,
});

View File

@@ -0,0 +1,56 @@
import { TinaMarkdown } from "tinacms/dist/rich-text";
import DocsMDXComponentRenderer from "../markdown-component-mapping";
export const Table = (props) => {
// Navigate through the nested structure to find the actual table content
const tableRows = props?.children?.props?.children || [];
const rowCount = tableRows.length;
return (
<div className="my-6 overflow-x-auto rounded-lg shadow-md">
<table className="w-full table-auto">
<tbody>
{tableRows.map((row, rowIndex) => {
// Each row has its own props.children array containing cells
const cells = row?.props?.children || [];
const CellComponent = rowIndex === 0 ? "th" : "td";
return (
<tr
key={`row-${rowIndex}`}
className={"bg-neutral-background-secondary/50"}
>
{cells.map((cell, cellIndex) => {
return (
<CellComponent
key={`cell-${rowIndex}-${cellIndex}`}
className={` px-4 pt-2 ${
rowIndex === 0
? " text-left font-tuner bg-neutral-background-secondary border-b-[0.5px] border-neutral-border "
: ""
} ${cellIndex === 0 ? "max-w-xs break-words" : ""}
${
rowIndex === 0 || rowIndex === rowCount - 1
? ""
: "border-b border-neutral-border"
}
`}
>
{cell?.props?.children}
<TinaMarkdown
content={cell?.props?.content as any}
components={DocsMDXComponentRenderer}
/>
</CellComponent>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
};
export default Table;

View File

@@ -0,0 +1,47 @@
"use client";
import { XMarkIcon } from "@heroicons/react/24/outline";
import React from "react";
import { useEffect } from "react";
import { useEditState } from "tinacms/dist/react";
const AdminLink = () => {
const { edit } = useEditState();
const [showAdminLink, setShowAdminLink] = React.useState(false);
useEffect(() => {
setShowAdminLink(
!edit &&
JSON.parse((window.localStorage.getItem("tinacms-auth") as any) || "{}")
?.access_token
);
}, [edit]);
const handleDismiss = () => {
setShowAdminLink(false);
};
return (
<>
{showAdminLink && (
<div className="fixed right-4 top-4 z-50 flex items-center justify-between rounded-full bg-blue-500 px-3 py-1 text-white">
<a
href={`/admin/index.html#/~${window.location.pathname}`}
className="text-xs"
>
Edit This Page
</a>
<button
type="button"
onClick={handleDismiss}
className="ml-2 text-sm"
>
<XMarkIcon className="size-4" />
</button>
</div>
)}
</>
);
};
export default AdminLink;

View File

@@ -0,0 +1,130 @@
import Link from "next/link";
import type React from "react";
interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
color?: "white" | "blue" | "orange" | "seafoam" | "ghost" | "ghostBlue";
size?: "large" | "small" | "medium" | "extraSmall";
className?: string;
href?: string;
type?: "button" | "submit" | "reset";
children: React.ReactNode | React.ReactNode[];
disabled?: boolean;
}
const baseClasses =
"transition duration-150 ease-out rounded-full flex items-center font-tuner whitespace-nowrap leading-snug focus:outline-none focus:shadow-outline hover:-translate-y-px active:translate-y-px hover:-translate-x-px active:translate-x-px leading-tight";
const raisedButtonClasses = "hover:shadow active:shadow-none";
const colorClasses = {
seafoam: `${raisedButtonClasses} text-orange-600 hover:text-orange-500 border border-seafoam-150 bg-gradient-to-br from-seafoam-50 to-seafoam-150`,
blue: `${raisedButtonClasses} text-white hover:text-gray-50 border border-blue-400 bg-gradient-to-br from-blue-300 via-blue-400 to-blue-600`,
orange: `${raisedButtonClasses} text-white hover:text-gray-50 border border-orange-600 bg-gradient-to-br from-orange-400 to-orange-600`,
white: `${raisedButtonClasses} text-orange-500 hover:text-orange-400 border border-gray-100/60 bg-gradient-to-br from-white to-gray-50`,
ghost: "text-orange-500 hover:text-orange-400",
orangeWithBorder:
"text-orange-500 hover:text-orange-400 border border-orange-500 bg-white",
ghostBlue: "text-blue-800 hover:text-blue-800",
};
const sizeClasses = {
large: "px-8 pt-[14px] pb-[12px] text-lg font-medium",
medium: "px-6 pt-[12px] pb-[10px] text-base font-medium",
small: "px-5 pt-[10px] pb-[8px] text-sm font-medium",
extraSmall: "px-4 pt-[8px] pb-[6px] text-xs font-medium",
};
export const Button = ({
color = "seafoam",
size = "medium",
className = "",
children,
...props
}: ButtonProps) => {
return (
<button
className={`${baseClasses} ${
colorClasses[color] ? colorClasses[color] : colorClasses.seafoam
} ${
sizeClasses[size] ? sizeClasses[size] : sizeClasses.medium
} ${className}`}
{...props}
>
{children}
</button>
);
};
export const LinkButton = ({
link = "/",
color = "seafoam",
size = "medium",
className = "",
children,
...props
}) => {
return (
<Link
href={link}
passHref
className={`${baseClasses} ${
colorClasses[color] ? colorClasses[color] : colorClasses.seafoam
} ${
sizeClasses[size] ? sizeClasses[size] : sizeClasses.medium
} ${className}`}
{...props}
>
{children}
</Link>
);
};
export const FlushButton = ({
link = "/",
color = "seafoam",
className = "",
children,
...props
}) => {
return (
<Link
href={link}
passHref
className={`${baseClasses} ${
colorClasses[color] ? colorClasses[color] : colorClasses.seafoam
} ${"hover:inner-link border-none bg-none p-2 hover:translate-x-0 hover:translate-y-0 hover:shadow-none"} ${className}`}
{...props}
>
{children}
</Link>
);
};
export const ModalButton = ({
color = "seafoam",
size = "medium",
className = "",
children,
...props
}) => {
return (
<button
className={`${baseClasses} ${
colorClasses[color] ? colorClasses[color] : colorClasses.seafoam
} ${
sizeClasses[size] ? sizeClasses[size] : sizeClasses.medium
} ${className}`}
{...props}
>
{children}
</button>
);
};
export const ButtonGroup = ({ children }) => {
return (
<div className="flex w-full flex-wrap items-center justify-start gap-4">
{children}
</div>
);
};

View File

@@ -0,0 +1,60 @@
import React from "react";
export const CustomColorToggle = ({ input }) => {
const { value = {}, onChange } = input;
const disableColor = value.disableColor || false;
const colorValue = value.colorValue || "#000000";
const handleCheckboxChange = (e) => {
onChange({ ...value, disableColor: e.target.checked });
};
const handleColorChange = (e) => {
onChange({ ...value, colorValue: e.target.value });
};
return (
<>
<label className="mb-2 block text-xs font-semibold text-gray-700">
Custom Background Selector
</label>
<div className="flex items-center pt-2">
<label className="flex cursor-pointer items-center">
<div className="relative shadow-lg">
<input
type="checkbox"
checked={disableColor}
onChange={handleCheckboxChange}
className="sr-only"
/>
<div
className={`h-5 w-10 rounded-full shadow-inner transition-colors duration-200 ${
disableColor ? "bg-green-500" : "bg-gray-300"
}`}
/>
<div
className={`absolute left-0 top-0 size-5 rounded-full bg-white shadow transition-transform duration-200 ${
disableColor ? "translate-x-full" : ""
}`}
/>
</div>
<span className="ml-3 text-gray-700">
Tick to use Default Background Color
</span>
</label>
{/* Color Picker */}
<div style={{ marginLeft: "1rem", opacity: disableColor ? 0.5 : 1 }}>
<input
type="color"
value={colorValue}
onChange={handleColorChange}
disabled={disableColor}
className="size-10 rounded border border-gray-300"
/>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,91 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@radix-ui/react-dropdown-menu";
import React, { useState } from "react";
import type { ReactNode } from "react";
import { MdArrowDropDown } from "react-icons/md";
export interface DropdownOption {
value: string;
label: ReactNode;
}
interface CustomDropdownProps {
/** The currently selected value */
value: string;
/** Function fired when a new option is selected */
onChange: (value: string) => void;
/** List of options to choose from */
options: DropdownOption[];
/** Placeholder text shown when no option is selected */
placeholder?: string;
/** Whether the dropdown is disabled */
disabled?: boolean;
/** Additional classes for the trigger button */
className?: string;
/** Additional classes for the dropdown content */
contentClassName?: string;
/** Additional classes for each menu item */
itemClassName?: string;
}
/**
* A reusable dropdown component built with Radix UI.
*
* It matches the full width of its trigger and automatically rotates the chevron icon when open.
*/
export const CustomDropdown = ({
value,
onChange,
options,
placeholder = "Select an option",
disabled = false,
className = "",
contentClassName = "",
itemClassName = "",
}: CustomDropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
// Find the label for the current value.
const activeOption = options.find((opt) => opt.value === value);
return (
<DropdownMenu onOpenChange={setIsOpen} open={disabled ? false : isOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
className={`w-full p-2 border border-gray-300 rounded-md shadow-sm text-neutral hover:bg-neutral-background-secondary focus:outline-none flex items-center justify-between gap-2 max-w-full overflow-x-hidden ${
disabled ? "opacity-50 cursor-not-allowed" : "hover:bg-gray-50"
} ${className}`}
>
<span className="truncate break-words whitespace-normal max-w-full text-left">
{activeOption ? activeOption.label : placeholder}
</span>
<MdArrowDropDown
className={`w-5 h-5 transition-transform duration-200 ${
isOpen ? "rotate-180" : "rotate-0"
} ${disabled ? "opacity-50" : ""}`}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className={`z-50 max-h-60 overflow-y-auto w-[var(--radix-dropdown-menu-trigger-width)] min-w-[200px] bg-white border border-gray-200 rounded-md shadow-lg ${contentClassName}`}
>
{options.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => onChange(opt.value)}
className={`px-3 py-2 cursor-pointer truncate break-words whitespace-normal max-w-full w-full focus:outline-none focus:ring-0 hover:bg-gray-100 ${itemClassName}`}
>
{opt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,27 @@
import Link, { type LinkProps } from "next/link";
import type React from "react";
type ExtraProps = Omit<LinkProps, "as" | "href">;
interface DynamicLinkProps extends ExtraProps {
href: string;
children?: React.ReactNode;
isFullWidth?: boolean;
}
export const DynamicLink = ({
href,
children,
isFullWidth = false,
...props
}: DynamicLinkProps) => {
return (
<Link
href={href}
{...props}
className={`cursor-pointer ${isFullWidth ? "" : ""}`}
>
{children}
</Link>
);
};

View File

@@ -0,0 +1,166 @@
"use client";
import Image, { type ImageLoader } from "next/image";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { MdClose } from "react-icons/md";
interface ImageOverlayWrapperProps {
children: React.ReactNode;
src: string;
alt: string;
caption?: string;
}
// Custom image loader to bypass Next.js image optimization
const customImageLoader: ImageLoader = ({ src, width, quality }) => {
// If it's already an absolute URL (starts with http:// or https://), return as-is
if (src.startsWith("http://") || src.startsWith("https://")) {
return src;
}
// For relative paths, prepend the base path if it exists
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || "";
const fullSrc = `${basePath}${src}`;
// If the src already includes query parameters, append with &, otherwise use ?
const separator = fullSrc.includes("?") ? "&" : "?";
return `${fullSrc}${separator}w=${width}&q=${quality || 75}`;
};
export const ImageOverlayWrapper = ({
children,
src,
alt,
caption,
}: ImageOverlayWrapperProps) => {
const [isOpen, setIsOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isOpen) {
// Disable scrolling when overlay is open
document.body.style.overflow = "hidden";
// Focus the overlay for keyboard interaction
if (overlayRef.current) {
overlayRef.current.focus();
}
return () => {
document.body.style.overflow = "unset";
};
}
}, [isOpen]);
const openOverlay = () => {
setIsOpen(true);
setIsLoading(true); // Reset loading state when opening overlay
};
const closeOverlay = () => setIsOpen(false);
const handleImageLoad = () => {
setIsLoading(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
closeOverlay();
}
};
const overlay =
isOpen && mounted
? createPortal(
<div
ref={overlayRef}
tabIndex={-1}
className="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-lg outline-none"
onClick={closeOverlay}
onKeyDown={handleKeyDown}
>
{/* Close button */}
<button
type="button"
onClick={closeOverlay}
className="absolute top-4 right-4 z-10 flex items-center justify-center w-10 h-10 border border-brand-primary hover:border-brand-primary-hover bg-neutral-background-secondary hover:bg-neutral-background-secondary/80 rounded-full transition-colors duration-200 group"
aria-label="Close image overlay"
>
<MdClose className="w-6 h-6 text-neutral-text group-hover:text-neutral-text-secondary" />
</button>
{/* Image container */}
<div className="relative max-w-[90vw] max-h-[90vh] flex items-center justify-center p-8">
<div className="relative flex flex-col items-center justify-center">
<div
className="relative w-[80vw] h-[80vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Loading skeleton */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-neutral-background-secondary/50 rounded-lg">
<div className="flex flex-col items-center gap-3">
<div className="w-12 h-12 border-4 border-brand-primary border-t-transparent rounded-full animate-spin" />
<p className="text-neutral-text-secondary text-sm">
Loading image...
</p>
</div>
</div>
)}
<Image
loader={customImageLoader}
src={src}
alt={alt}
fill
style={{ objectFit: "contain", objectPosition: "center" }}
onLoad={handleImageLoad}
/>
</div>
{/* Caption */}
{caption && (
<div
className="mt-4 px-4 py-2 rounded-lg bg-neutral-background"
onClick={(e) => e.stopPropagation()}
>
<p className="text-neutral-text text-sm text-center font-light">
{caption}
</p>
</div>
)}
</div>
</div>
{/* Click anywhere to close hint */}
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2">
<p className="text-neutral-text-secondary text-sm">
Click anywhere to close
</p>
</div>
</div>,
document.body
)
: null;
return (
<>
<button
type="button"
onClick={openOverlay}
className="cursor-pointer transition-opacity duration-200 hover:opacity-80 active:opacity-90 border-none bg-transparent p-0 md:block w-full flex justify-center"
aria-label={`Open image overlay: ${alt}`}
>
{children}
</button>
{overlay}
</>
);
};

View File

@@ -0,0 +1,37 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { IoMoon, IoSunny } from "react-icons/io5";
export default function LightDarkSwitch() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const isLight = resolvedTheme === "light";
return (
<button
type="button"
className="flex items-center justify-center w-10 h-10 rounded-full transition-all duration-300 ease-in-out cursor-pointer"
onClick={() => setTheme(isLight ? "dark" : "light")}
>
{mounted ? (
isLight ? (
<IoSunny
size={20}
className="text-brand-primary transition-colors duration-300"
/>
) : (
<IoMoon
size={19}
className="text-neutral-text transition-colors duration-300"
/>
)
) : (
<div className="w-5 h-5 rounded-full animate-pulse opacity-20" />
)}
</button>
);
}

View File

@@ -0,0 +1,127 @@
import { usePathname } from "next/navigation";
import React from "react";
import { MdChevronLeft, MdChevronRight } from "react-icons/md";
import { useNavigation } from "../docs/layout/navigation-context";
import { DynamicLink } from "./dynamic-link";
export function Pagination() {
const [prevPage, setPrevPage] = React.useState<any>(null);
const [nextPage, setNextPage] = React.useState<any>(null);
const pathname = usePathname();
const docsData = useNavigation();
React.useEffect(() => {
if (!docsData?.data) return;
// Flatten the hierarchical structure into a linear array
const flattenItems = (items: any[]): any[] => {
const flattened: any[] = [];
const traverse = (itemList: any[]) => {
for (const item of itemList) {
if (item.slug) {
flattened.push({
slug: item.slug.id,
title: item.slug.title,
});
}
if (item.items) {
// This has nested items, traverse them
traverse(item.items);
}
}
};
traverse(items);
return flattened;
};
const getAllPages = (): any[] => {
const allPages: any[] = [];
for (const tab of docsData.data) {
if (tab.items) {
const flattenedItems = flattenItems(tab.items);
allPages.push(...flattenedItems);
}
}
return allPages;
};
// Get current slug from pathname
const slug =
pathname === "/docs"
? "content/docs/index.mdx"
: `content${pathname}.mdx`;
// Get all pages in sequence
const allPages = getAllPages();
// Find current page index
const currentIndex = allPages.findIndex((page: any) => page.slug === slug);
if (currentIndex !== -1) {
// Set previous page (if exists)
const prev = currentIndex > 0 ? allPages[currentIndex - 1] : null;
setPrevPage(prev);
// Set next page (if exists)
const next =
currentIndex < allPages.length - 1 ? allPages[currentIndex + 1] : null;
setNextPage(next);
} else {
setPrevPage(null);
setNextPage(null);
}
}, [docsData, pathname]);
return (
<div className="flex justify-between mt-2 py-4 rounded-lg gap-4 w-full">
{prevPage?.slug ? (
//Slices to remove content/ and .mdx from the filepath, and removes /index for index pages
<DynamicLink
href={prevPage.slug.slice(7, -4).replace(/\/index$/, "/")}
passHref
>
<div className="group relative block cursor-pointer py-4 text-left transition-all">
<span className="pl-10 text-sm uppercase opacity-50 group-hover:opacity-100 text-neutral-text-secondary">
Previous
</span>
<h5 className="pl m-0 flex items-center font-light leading-[1.3] text-brand-secondary opacity-80 group-hover:opacity-100 transition-all duration-150 ease-out group-hover:text-brand-primary md:text-xl">
<MdChevronLeft className="ml-2 size-7 fill-gray-400 transition-all duration-150 ease-out group-hover:fill-brand-primary" />
<span className="relative brand-secondary-gradient">
{prevPage.title}
<span className="absolute bottom-0 left-0 w-0 h-[1.5px] bg-gradient-to-r from-brand-secondary-gradient-start to-brand-secondary-gradient-end group-hover:w-full transition-all duration-300 ease-in-out" />
</span>
</h5>
</div>
</DynamicLink>
) : (
<div />
)}
{nextPage?.slug ? (
//Slices to remove content/ and .mdx from the filepath, and removes /index for index pages
<DynamicLink
href={nextPage.slug.slice(7, -4).replace(/\/index$/, "/")}
passHref
>
<div className="group relative col-start-2 block cursor-pointer p-4 text-right transition-all">
<span className="pr-6 text-sm uppercase opacity-50 md:pr-10 group-hover:opacity-100 text-neutral-text-secondary">
Next
</span>
<h5 className="m-0 flex items-center justify-end font-light leading-[1.3] text-brand-secondary opacity-80 group-hover:opacity-100 transition-all duration-150 ease-out group-hover:text-brand-primary md:text-xl">
<span className="relative brand-secondary-gradient">
{nextPage.title}
<span className="absolute bottom-0 left-0 w-0 h-[1.5px] bg-gradient-to-r from-brand-secondary-gradient-start to-brand-secondary-gradient-end group-hover:w-full transition-all duration-300 ease-in-out" />
</span>
<MdChevronRight className="ml-2 size-7 fill-gray-400 transition-all duration-150 ease-out group-hover:fill-brand-primary" />
</h5>
</div>
</DynamicLink>
) : (
<div />
)}
</div>
);
}

View File

@@ -0,0 +1,14 @@
export function TailwindIndicator() {
if (process.env.NODE_ENV === "production") return null;
return (
<div className="fixed bottom-1 left-1 z-50 flex h-6 w-6 items-center justify-center rounded-full bg-gray-800 p-3 font-mono text-xs text-white">
<div className="block sm:hidden">xs</div>
<div className="hidden sm:block md:hidden">sm</div>
<div className="hidden md:block lg:hidden">md</div>
<div className="hidden lg:block xl:hidden">lg</div>
<div className="hidden xl:block 2xl:hidden">xl</div>
<div className="hidden 2xl:block">2xl</div>
</div>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useRef, useState } from "react";
import { MdArrowDropDown } from "react-icons/md";
import { MdHelpOutline } from "react-icons/md";
const themes = ["default", "tina", "blossom", "lake", "pine", "indigo"];
export const BROWSER_TAB_THEME_KEY = "browser-tab-theme";
// Default theme colors from root
const DEFAULT_COLORS = {
background: "#FFFFFF",
text: "#000000",
border: "#000000",
};
export const ThemeSelector = () => {
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [showTooltip, setShowTooltip] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const [selectedTheme, setSelectedTheme] = useState(() => {
if (typeof window !== "undefined") {
return sessionStorage.getItem(BROWSER_TAB_THEME_KEY) || theme;
}
return theme;
});
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
if (
tooltipRef.current &&
!tooltipRef.current.contains(event.target as Node)
) {
setShowTooltip(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Avoid hydration mismatch
useEffect(() => {
setMounted(true);
}, []);
// Update selected theme when theme changes from dropdown
useEffect(() => {
if (theme && !themes.includes(theme)) {
// If theme is not in our list, it means it's a dark/light mode change
setSelectedTheme(selectedTheme);
} else {
setSelectedTheme(theme);
}
}, [theme, selectedTheme]);
useEffect(() => {
if (mounted && selectedTheme) {
const isDark = resolvedTheme === "dark";
document.documentElement.className = `theme-${selectedTheme}${
isDark ? " dark" : ""
}`;
sessionStorage.setItem(BROWSER_TAB_THEME_KEY, selectedTheme);
}
}, [selectedTheme, resolvedTheme, mounted]);
if (!mounted) return null;
const handleThemeChange = (newTheme: string) => {
const currentMode = resolvedTheme;
setSelectedTheme(newTheme);
sessionStorage.setItem(BROWSER_TAB_THEME_KEY, newTheme);
setIsOpen(false);
if (currentMode === "dark") {
setTheme("light");
setTimeout(() => setTheme("dark"), 0);
} else {
setTheme("light");
}
};
return (
<div className="fixed bottom-4 right-4 z-50 bg-neutral-surface p-1 rounded-lg shadow-lg">
<div className="relative" ref={dropdownRef}>
<div className="flex items-center gap-2">
<div className="relative" ref={tooltipRef}>
<button
type="button"
onClick={() => setShowTooltip(!showTooltip)}
className="w-6 h-6 rounded-full bg-neutral-hover hover:bg-neutral-border flex items-center justify-center text-neutral-text transition-colors"
aria-label="Theme help"
>
<MdHelpOutline className="w-4 h-4" />
</button>
{showTooltip && <Tooltip selectedTheme={selectedTheme} />}
</div>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-[120px] rounded-md border border-neutral-border bg-neutral-surface px-3 py-1 text-sm text-neutral-text focus:outline-none focus:ring-2 focus:ring-brand-primary flex items-center justify-between cursor-pointer"
>
<span className="truncate">
{selectedTheme.charAt(0).toUpperCase() + selectedTheme.slice(1)}
</span>
<MdArrowDropDown
className={`w-4 h-4 text-brand-secondary-dark-dark transition-transform duration-200 flex-shrink-0 ${
isOpen ? "rotate-180" : ""
}`}
/>
</button>
</div>
{isOpen && (
<div className="absolute bottom-full left-0 right-0 mb-1 bg-neutral-surface rounded-md border border-neutral-border shadow-lg overflow-hidden w-[120px]">
{themes.map((t) => (
<button
type="button"
key={t}
onClick={() => handleThemeChange(t)}
className={`w-full px-3 py-1 text-sm text-left hover:bg-neutral-hover transition-colors cursor-pointer first:rounded-t-md last:rounded-b-md my-0.25 first:mt-0 last:mb-0 ${
t === "default" ? "" : `theme-${t}`
} ${t === selectedTheme ? "bg-neutral-hover" : ""}`}
style={{
backgroundColor:
t === "default"
? DEFAULT_COLORS.background
: "var(--brand-primary-light)",
color:
t === "default"
? DEFAULT_COLORS.text
: "var(--brand-primary)",
border:
t === "default"
? `1px solid ${DEFAULT_COLORS.border}`
: "1px solid var(--brand-primary)",
}}
>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
)}
</div>
</div>
);
};
const Tooltip = ({ selectedTheme }: { selectedTheme: string }) => {
return (
<div className="absolute bottom-full right-0 mb-2 w-64 p-3 bg-neutral-surface border border-neutral-border rounded-lg shadow-lg text-xs text-neutral-text min-w-fit">
<div className="font-medium mb-2">Theme Preview</div>
<p className="mb-2">
Theme changes are temporary and will reset when you open a new browser
window or tab.
</p>
<p className="mb-2">
To make theme changes permanent, update the{" "}
<code className="bg-neutral-hover px-1 rounded">Selected Theme</code>{" "}
field in your Settings through TinaCMS:
</p>
<code className="block bg-neutral-hover p-2 rounded text-xs font-mono">
selectedTheme={selectedTheme}
</code>
<div className="absolute top-full right-2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-neutral-border" />
</div>
);
};

63
src/constants/index.ts Normal file
View File

@@ -0,0 +1,63 @@
export const ADD_PENDING_DOCUMENT_MUTATION = `
mutation AddPendingDocument($collection: String!, $relativePath: String!) {
addPendingDocument(collection: $collection, relativePath: $relativePath) {
__typename
}
}
`;
export const UPDATE_DOCS_MUTATION = `
mutation UpdateDocs($relativePath: String!, $params: DocsMutation!) {
updateDocs(relativePath: $relativePath, params: $params) {
__typename
id
title
}
}
`;
export const DELETE_DOCUMENT_MUTATION = `
mutation DeleteDocument($collection: String!, $relativePath: String!) {
deleteDocument(collection: $collection, relativePath: $relativePath) {
__typename
}
}
`;
export const GET_DOC_BY_RELATIVE_PATH_QUERY = `
query GetDocByRelativePath($relativePath: String!) {
docs(relativePath: $relativePath) {
id
title
auto_generated
seo {
title
description
canonicalUrl
}
auto_generated
last_edited
body
_sys {
filename
relativePath
}
}
}
`;
export const GET_ALL_DOCS_QUERY = `
query GetAllDocs {
docsConnection {
edges {
node {
id
_sys {
filename
relativePath
}
}
}
}
}
`;

View File

@@ -0,0 +1,21 @@
import { useEffect, useState } from "react";
export function useScreenResizer() {
const [isScreenSmallerThan1200, setIsScreenSmallerThan1200] = useState(false);
const [isScreenSmallerThan840, setIsScreenSmallerThan840] = useState(false);
useEffect(() => {
const updateScreenSize = () => {
setIsScreenSmallerThan1200(window.innerWidth < 1200);
setIsScreenSmallerThan840(window.innerWidth < 840);
};
updateScreenSize();
window.addEventListener("resize", updateScreenSize);
return () => window.removeEventListener("resize", updateScreenSize);
}, []);
return { isScreenSmallerThan1200, isScreenSmallerThan840 };
}

View File

@@ -0,0 +1,31 @@
import { notFound } from "next/navigation";
export enum FileType {
MDX = "mdx",
JSON = "json",
}
export async function fetchTinaData<T, V>(
queryFunction: (
variables?: V,
options?: unknown
) => Promise<{
data: T;
variables: V;
query: string;
}>,
filename?: string,
type: FileType = FileType.MDX
): Promise<{ data: T; variables: V; query: string }> {
try {
const variables: V = {
relativePath: filename ? `${filename}.${type}` : "",
} as V;
const response = await queryFunction(variables);
return response;
} catch {
notFound();
}
}

583
src/styles/global.css Normal file
View File

@@ -0,0 +1,583 @@
@import "tailwindcss";
@config "../../tailwind.config.js";
/* DEFAULT MONOCHROME THEME */
:root {
/* Tailwind Slate's */
--brand-primary: #0f172a;
--brand-primary-hover: #334155;
--brand-primary-light: #b7c5d8;
--brand-primary-text: #0f172a;
--brand-primary-contrast: #334155;
--brand-primary-gradient-start: #0f172a;
--brand-primary-gradient-end: #0f172a;
--brand-secondary: #0f172a;
--brand-secondary-hover: #334155;
--brand-secondary-light: #b5becd;
--brand-secondary-text: #0f172a;
--brand-secondary-contrast: #334155;
--brand-secondary-gradient-start: #0f172a;
--brand-secondary-gradient-end: #0f172a;
--brand-tertiary: #0f172a;
--brand-tertiary-hover: #334155;
--brand-tertiary-light: #64748b;
--brand-tertiary-text: #0f172a;
--brand-tertiary-contrast: #334155;
--brand-tertiary-gradient-start: #0f172a;
--brand-tertiary-gradient-end: #0f172a;
--glass-gradient-start: rgba(255, 255, 255, 0.1);
--glass-gradient-end: rgba(255, 255, 255, 0.4);
--neutral-background: #F8FAFC;
--neutral-background-secondary: #F1F5F9;
--neutral-surface: #FFFFFF;
--neutral-text: #3E4342;
--neutral-text-secondary: #64748B;
--neutral-border: #CBD5E1;
--neutral-border-subtle: #e2e8f0;
--background-color: #f8fafc;
--background-brand-code: #ffffff;
}
.dark {
--brand-primary: #f1f5f9;
--brand-primary-hover: #94a3b8;
--brand-primary-light: #4f555e;
--brand-primary-text: #f1f5f9;
--brand-primary-contrast: #f1f5f9;
--brand-primary-gradient-start: #f1f5f9;
--brand-primary-gradient-end: #f1f5f9;
--brand-secondary: #f1f5f9;
--brand-secondary-hover: #94a3b8;
--brand-secondary-light: #4b4f53;
--brand-secondary-text: #f1f5f9;
--brand-secondary-contrast: #f1f5f9;
--brand-secondary-gradient-start: #f1f5f9;
--brand-secondary-gradient-end: #f1f5f9;
--brand-tertiary: #f1f5f9;
--brand-tertiary-hover: #94a3b8;
--brand-tertiary-light: #cbd5e1;
--brand-tertiary-text: #f1f5f9;
--brand-tertiary-contrast: #f1f5f9;
--brand-tertiary-gradient-start: #f1f5f9;
--brand-tertiary-gradient-end: #f1f5f9;
--glass-gradient-start: rgba(51, 65, 85, 0.25);
--glass-gradient-end: rgba(29,44,108,0.0);
--neutral-background: #0f172a;
--neutral-surface: #1A202C;
--neutral-background-secondary: #1F2937;
--neutral-text: #F9FAFB;
--neutral-text-secondary: #9CA3AF;
--neutral-border: #64748b;
--neutral-border-subtle: #4B5563;
--background-color: #020617;
--background-brand-code: #011627;
}
/* TINACMS THEME */
.theme-tina {
--brand-primary: #EC4815;
--brand-primary-hover: #D13F13;
--brand-primary-light: #FFD3c1;
--brand-primary-text: #D13F13;
--brand-primary-contrast: #7a230b;
--brand-primary-gradient-start: #FF724B;
--brand-primary-gradient-end: #D13F13;
--brand-secondary: #0084FF;
--brand-secondary-hover: #0574E4;
--brand-secondary-light: #DCEEFF;
--brand-secondary-text: #0574e4;
--brand-secondary-contrast: #1d2c6c;
--brand-secondary-gradient-start: #144696;
--brand-secondary-gradient-end: #1d2c6c;
--brand-tertiary: #93E9BE;
--brand-tertiary-hover: #72c39b;
--brand-tertiary-light: #EEFDF9;
--brand-tertiary-text: #39745c;
--brand-tertiary-contrast: #214c3d;
--brand-tertiary-gradient-start: #B4EFD9;
--brand-tertiary-gradient-end: #72C39b;
--glass-gradient-start: rgba(255, 255, 255, 0.1);
--glass-gradient-end: rgba(255, 255, 255, 0.4);
--neutral-background: #F8FAFC;
--neutral-background-secondary: #F1F5F9;
--neutral-background-quaternary: #E2E8F0;
--neutral-background-quinary: #F1F5F9;
--neutral-surface: #faebe5;
--neutral-text: #0f172a;
--neutral-text-secondary: #64748b;
--neutral-border: #CBD5E1;
--neutral-border-subtle: #e2e8f0;
--background-color: #e4f6f969;
--glass-hover-gradient-start: oklch(98.5% 0 0);
--glass-hover-gradient-end: oklch(95.6% 0.045 203.388);
}
.theme-tina.dark {
--brand-primary: #FF6A2D;
--brand-primary-hover: #FF814C;
--brand-primary-light: #7a230b;
--brand-primary-text: #FF6a2d;
--brand-primary-contrast: #FFAB8B;
--brand-primary-gradient-start: #FFAB8B;
--brand-primary-gradient-end: #D13F13;
--brand-secondary: #5a9cff;
--brand-secondary-hover: #7fb2ff;
--brand-secondary-light: #144696;
--brand-secondary-text: #82b4ff;
--brand-secondary-contrast: #dceefd;
--brand-secondary-gradient-start: #85C5FE;
--brand-secondary-gradient-end: #0574E4;
--brand-tertiary: #529C7B;
--brand-tertiary-hover: #CFF5E6;
--brand-tertiary-light: #529c7b;
--brand-tertiary-text: #c1f5eb;
--brand-tertiary-contrast: #e9fbf4;
--brand-tertiary-gradient-start: #9FE2BF;
--brand-tertiary-gradient-end: #6AC09A;
--glass-gradient-start: rgba(30, 41, 59, 0.80);
--glass-gradient-end: rgba(29,44,108,0.0);
--neutral-background: #0f172a;
--neutral-surface: #1A202C;
--neutral-background-secondary: #1F2937;
--neutral-background-tertiary: #1A202C;
--neutral-background-quaternary: #0f172a;
--neutral-background-quinary: #1E293B;
--neutral-text: #F9FAFB;
--neutral-text-secondary: #9CA3AF;
--neutral-border: #64748b;
--neutral-border-subtle: #4B5563;
--background-color: #051619;
}
.theme-blossom {
--brand-primary: #e54666; /* ruby8 */
--brand-primary-hover: #dc3b5d; /* ruby10 */
--brand-primary-light: #ffcdcf; /* ruby5 */
--brand-primary-text: #ca244d; /* ruby11 */
--brand-primary-contrast: #64172b; /* ruby12 */
--brand-primary-gradient-start: #e54666; /* ruby9 */
--brand-primary-gradient-end: #ca244d; /* ruby11 */
--brand-secondary: #e54666; /* ruby8 */
--brand-secondary-hover: #dc3b5d; /* ruby10 */
--brand-secondary-light: #ffcdcf; /* ruby5 */
--brand-secondary-text: #ca244d; /* ruby11 */
--brand-secondary-contrast: #64172b; /* ruby12 */
--brand-secondary-gradient-start: #e54666; /* ruby9 */
--brand-secondary-gradient-end: #ca244d; /* ruby11 */
--brand-tertiary: #e54666; /* ruby8 */
--brand-tertiary-hover: #dc3b5d; /* ruby10 */
--brand-tertiary-light: #ffcdcf; /* ruby5 */
--brand-tertiary-text: #ca244d; /* ruby11 */
--brand-tertiary-contrast: #64172b; /* ruby12 */
--brand-tertiary-gradient-start: #f4a9aa; /* ruby7 */
--brand-tertiary-gradient-end: #e54666; /* ruby9 */
--glass-gradient-start: rgba(255, 255, 255, 0.1);
--glass-gradient-end: rgba(255, 255, 255, 0.4);
--neutral-background: #fffcfd; /* ruby1 */
--neutral-background-secondary: #fff7f8; /* ruby2 */
--neutral-surface: #feebec; /* ruby3 */
--neutral-text: #0f172a; /* slate 900 */
--neutral-text-secondary: #64748b; /* slate 500 */
--neutral-border: #fdbdbe; /* ruby6 */
--neutral-border-subtle: #ffdce1; /* ruby4 */
--background-color: #f9f9f9;
}
.theme-blossom.dark {
--brand-primary: #e54666; /* rubyDark8 */
--brand-primary-hover: #ec5a72; /* rubyDark10 */
--brand-primary-light: #5e3140; /* rubyDark5 */
--brand-primary-text: #ff949d; /* rubyDark11 */
--brand-primary-contrast: #feecee; /* rubyDark12 */
--brand-primary-gradient-start: #e54666; /* rubyDark9 */
--brand-primary-gradient-end: #ff949d; /* rubyDark11 */
--brand-secondary: #b4718a; /* rubyDark8 */
--brand-secondary-hover: #ec5a72; /* rubyDark10 */
--brand-secondary-light: #5e3140; /* rubyDark5 */
--brand-secondary-text: #ff949d; /* rubyDark11 */
--brand-secondary-contrast: #feecee; /* rubyDark12 */
--brand-secondary-gradient-start: #e54666; /* rubyDark9 */
--brand-secondary-gradient-end: #ff949d; /* rubyDark11 */
--brand-tertiary: #b4718a; /* rubyDark8 */
--brand-tertiary-hover: #ec5a72; /* rubyDark10 */
--brand-tertiary-light: #5e3140; /* rubyDark5 */
--brand-tertiary-text: #ff949d; /* rubyDark11 */
--brand-tertiary-contrast: #feecee; /* rubyDark12 */
--brand-tertiary-gradient-start: #92596c; /* rubyDark7 */
--brand-tertiary-gradient-end: #e54666; /* rubyDark9 */
--glass-gradient-start: rgba(94, 26, 46, 0.30);
--glass-gradient-end: rgba(29,44,108,0.0);
--neutral-background: #191113; /* rubyDark1 */
--neutral-background-secondary: #201318; /* rubyDark2 */
--neutral-surface: #3a1e22; /* rubyDark3 */
--neutral-text: #f8fafc; /* slate 50 */
--neutral-text-secondary: #cbd5e1; /* slate 300 */
--neutral-border: #734253; /* rubyDark6 */
--neutral-border-subtle: #4e1325; /* rubyDark4 */
--background-color: #020202;
}
.theme-lake {
--brand-primary: #0090FF; /* blue9 */
--brand-primary-hover: #2563eb; /* blue10 */
--brand-primary-light: #bfdbfe; /* blue5 */
--brand-primary-text: #1d4ed8; /* blue11 */
--brand-primary-contrast: #1e3a8a; /* blue12 */
--brand-primary-gradient-start: #60a5fa; /* blue9 */
--brand-primary-gradient-end: #1d4ed8; /* blue11 */
--brand-secondary: #3b82f6; /* blue8 */
--brand-secondary-hover: #2563eb; /* blue10 */
--brand-secondary-light: #bfdbfe; /* blue5 */
--brand-secondary-text: #1d4ed8; /* blue11 */
--brand-secondary-contrast: #1e3a8a; /* blue12 */
--brand-secondary-gradient-start: #60a5fa; /* blue9 */
--brand-secondary-gradient-end: #1d4ed8; /* blue11 */
--brand-tertiary: #3b82f6; /* blue8 */
--brand-tertiary-hover: #2563eb; /* blue10 */
--brand-tertiary-light: #bfdbfe; /* blue5 */
--brand-tertiary-text: #1d4ed8; /* blue11 */
--brand-tertiary-contrast: #1e3a8a; /* blue12 */
--brand-tertiary-gradient-start: #93c5fd; /* blue7 */
--brand-tertiary-gradient-end: #60a5fa; /* blue9 */
--glass-gradient-start: rgba(255, 255, 255, 0.1);
--glass-gradient-end: rgba(255, 255, 255, 0.4);
--neutral-background: #fefbff; /* blue1 */
--neutral-background-secondary: #f8fafc; /* blue2 */
--neutral-surface: #f1f5f9; /* blue3 */
--neutral-text: #0f172a; /* slate 900 */
--neutral-text-secondary: #64748b; /* slate 500 */
--neutral-border:#ACD8FC; /* blue6 */
--neutral-border-subtle: #D5EFFF; /* blue4 */
--background-color: #f9f9f9;
}
.theme-lake.dark {
--brand-primary: #0090FF; /* blueDark9 */
--brand-primary-hover: #2563eb; /* blueDark10 */
--brand-primary-light: #1e3a8a; /* blueDark5 */
--brand-primary-text: #60a5fa; /* blueDark11 */
--brand-primary-contrast: #dbeafe; /* blueDark12 */
--brand-primary-gradient-start: #3b82f6; /* blueDark9 */
--brand-primary-gradient-end: #60a5fa; /* blueDark11 */
--brand-secondary: #1d4ed8; /* blueDark8 */
--brand-secondary-hover: #2563eb; /* blueDark10 */
--brand-secondary-light: #1e3a8a; /* blueDark5 */
--brand-secondary-text: #60a5fa; /* blueDark11 */
--brand-secondary-contrast: #dbeafe; /* blueDark12 */
--brand-secondary-gradient-start: #3b82f6; /* blueDark9 */
--brand-secondary-gradient-end: #60a5fa; /* blueDark11 */
--brand-tertiary: #1d4ed8; /* blueDark8 */
--brand-tertiary-hover: #2563eb; /* blueDark10 */
--brand-tertiary-light: #1e3a8a; /* blueDark5 */
--brand-tertiary-text: #60a5fa; /* blueDark11 */
--brand-tertiary-contrast: #dbeafe; /* blueDark12 */
--brand-tertiary-gradient-start: #1e40af; /* blueDark7 */
--brand-tertiary-gradient-end: #3b82f6; /* blueDark9 */
--glass-gradient-start: rgba(0, 51, 98, 0.3);
--glass-gradient-end: rgba(29,44,108,0.0);
--neutral-background: #0f172a; /* blueDark1 */
--neutral-background-secondary: #1e293b; /* blueDark2 */
--neutral-surface: #334155; /* blueDark3 */
--neutral-text: #f8fafc; /* slate 50 */
--neutral-text-secondary: #cbd5e1; /* slate 300 */
--neutral-border: #104d87; /* blueDark6 */
--neutral-border-subtle: #003362; /* blueDark4 */
--background-color: #020202;
}
.theme-pine {
--brand-primary: #30A46C; /* grass8 */
--brand-primary-hover: #2b9a66; /* grass10 */
--brand-primary-light: #c4e8d1; /* grass5 */
--brand-primary-text: #218358; /* grass11 */
--brand-primary-contrast: #193b2d; /* grass12 */
--brand-primary-gradient-start: #30a46c; /* grass9 */
--brand-primary-gradient-end: #218358; /* grass11 */
--brand-secondary: #5bb98c; /* grass8 */
--brand-secondary-hover: #2b9a66; /* grass10 */
--brand-secondary-light: #c4e8d1; /* grass5 */
--brand-secondary-text: #218358; /* grass11 */
--brand-secondary-contrast: #193b2d; /* grass12 */
--brand-secondary-gradient-start: #30a46c; /* grass9 */
--brand-secondary-gradient-end: #218358; /* grass11 */
--brand-tertiary: #5bb98c; /* grass8 */
--brand-tertiary-hover: #2b9a66; /* grass10 */
--brand-tertiary-light: #c4e8d1; /* grass5 */
--brand-tertiary-text: #218358; /* grass11 */
--brand-tertiary-contrast: #193b2d; /* grass12 */
--brand-tertiary-gradient-start: #8eceaa; /* grass7 */
--brand-tertiary-gradient-end: #30a46c; /* grass9 */
--glass-gradient-start: rgba(255, 255, 255, 0.1);
--glass-gradient-end: rgba(255, 255, 255, 0.4);
--neutral-background: #fbfefb; /* grass1 */
--neutral-background-secondary: #f5fbf5; /* grass2 */
--neutral-surface: #e9f6e9; /* grass3 */
--neutral-text: #0f172a; /* slate 900 */
--neutral-text-secondary: #64748b; /* slate 500 */
--neutral-border: #65ba74; /* grass8 */
--neutral-border-subtle: #b2ddb5; /* grass6 */
--background-color: #f9f9f9;
}
.theme-pine.dark {
--brand-primary: #30A46C; /* greenDark9 */
--brand-primary-hover: #33b074; /* grassDark10 */
--brand-primary-light: #174933; /* grassDark5 */
--brand-primary-text: #3dd68c; /* grassDark11 */
--brand-primary-contrast: #b1f1cb; /* grassDark12 */
--brand-primary-gradient-start: #30a46c; /* grassDark9 */
--brand-primary-gradient-end: #3dd68c; /* grassDark11 */
--brand-secondary: #2f7c57; /* grassDark8 */
--brand-secondary-hover: #33b074; /* grassDark10 */
--brand-secondary-light: #174933; /* grassDark5 */
--brand-secondary-text: #3dd68c; /* grassDark11 */
--brand-secondary-contrast: #b1f1cb; /* grassDark12 */
--brand-secondary-gradient-start: #30a46c; /* grassDark9 */
--brand-secondary-gradient-end: #3dd68c; /* grassDark11 */
--brand-tertiary: #2f7c57; /* grassDark8 */
--brand-tertiary-hover: #33b074; /* grassDark10 */
--brand-tertiary-light: #174933; /* grassDark5 */
--brand-tertiary-text: #3dd68c; /* grassDark11 */
--brand-tertiary-contrast: #b1f1cb; /* grassDark12 */
--brand-tertiary-gradient-start: #28684a; /* grassDark7 */
--brand-tertiary-gradient-end: #30a46c; /* grassDark9 */
--glass-gradient-start: rgba(37, 72, 45, 0.25);
--glass-gradient-end: rgba(0,0,0,0.0);
--neutral-background: #0e1512; /* grassDark1 */
--neutral-background-secondary: #121b17; /* grassDark2 */
--neutral-surface: #132d21; /* grassDark3 */
--neutral-text: #f8fafc; /* slate 50 */
--neutral-text-secondary: #cbd5e1; /* slate 300 */
--neutral-border: #2f7c57; /* grassDark8 */
--neutral-border-subtle: #20573e; /* grassDark6 */
--background-color: #020202;
}
.theme-indigo {
--brand-primary: #6E56CF /* violet 9*/ ; /* violet8 */
--brand-primary-hover: #7c3aed; /* violet10 */
--brand-primary-light: #e1d9ff; /* violet5 */
--brand-primary-text: #6d28d9; /* violet11 */
--brand-primary-contrast: #2e1065; /* violet12 */
--brand-primary-gradient-start: #8b5cf6; /* violet9 */
--brand-primary-gradient-end: #6d28d9; /* violet11 */
--brand-secondary: #b197fc; /* violet8 */
--brand-secondary-hover: #7c3aed; /* violet10 */
--brand-secondary-light: #e1d9ff; /* violet5 */
--brand-secondary-text: #6d28d9; /* violet11 */
--brand-secondary-contrast: #2e1065; /* violet12 */
--brand-secondary-gradient-start: #8b5cf6; /* violet9 */
--brand-secondary-gradient-end: #6d28d9; /* violet11 */
--brand-tertiary: #b197fc; /* violet8 */
--brand-tertiary-hover: #7c3aed; /* violet10 */
--brand-tertiary-light: #e1d9ff; /* violet5 */
--brand-tertiary-text: #6d28d9; /* violet11 */
--brand-tertiary-contrast: #2e1065; /* violet12 */
--brand-tertiary-gradient-start: #c4b5fd; /* violet7 */
--brand-tertiary-gradient-end: #8b5cf6; /* violet9 */
--glass-gradient-start: rgba(255, 255, 255, 0.1);
--glass-gradient-end: rgba(255, 255, 255, 0.4);
--neutral-background: #fdfcfe; /* violet1 */
--neutral-background-secondary: #faf8ff; /* violet2 */
--neutral-surface: #f4f0fe; /* violet3 */
--neutral-text: #0f172a; /* slate 900 */
--neutral-text-secondary: #64748b; /* slate 500 */
--neutral-border: #d4cafe; /* violet6 */
--neutral-border-subtle: #ebe4ff; /* violet4 */
--background-color: #f9f9f9;
}
.theme-indigo.dark {
--brand-primary: #6E56CF; /* violetDark8 */
--brand-primary-hover: #9d71ff; /* violetDark10 */
--brand-primary-light: #3e2c72; /* violetDark5 */
--brand-primary-text: #b794f6; /* violetDark11 */
--brand-primary-contrast: #e2d6ff; /* violetDark12 */
--brand-primary-gradient-start: #8b5cf6; /* violetDark9 */
--brand-primary-gradient-end: #b794f6; /* violetDark11 */
--brand-secondary: #7c66dc; /* violetDark8 */
--brand-secondary-hover: #9d71ff; /* violetDark10 */
--brand-secondary-light: #3e2c72; /* violetDark5 */
--brand-secondary-text: #b794f6; /* violetDark11 */
--brand-secondary-contrast: #e2d6ff; /* violetDark12 */
--brand-secondary-gradient-start: #8b5cf6; /* violetDark9 */
--brand-secondary-gradient-end: #b794f6; /* violetDark11 */
--brand-tertiary: #7c66dc; /* violetDark8 */
--brand-tertiary-hover: #9d71ff; /* violetDark10 */
--brand-tertiary-light: #3e2c72; /* violetDark5 */
--brand-tertiary-text: #b794f6; /* violetDark11 */
--brand-tertiary-contrast: #e2d6ff; /* violetDark12 */
--brand-tertiary-gradient-start: #614bb3; /* violetDark7 */
--brand-tertiary-gradient-end: #8b5cf6; /* violetDark9 */
--glass-gradient-start: rgba(51, 37, 91, 0.30);
--glass-gradient-end: rgba(0,0,0,0.0);
--neutral-background: #14121f; /* violetDark1 */
--neutral-background-secondary: #1b1525; /* violetDark2 */
--neutral-surface: #291f43; /* violetDark3 */
--neutral-text: #f8fafc; /* slate 50 */
--neutral-text-secondary: #cbd5e1; /* slate 300 */
--neutral-border: #473876; /* violetDark6 */
--neutral-border-subtle: #33255b; /* violetDark4 */
--background-color: #020202;
}
/* Shared styles */
.brand-primary-gradient {
background-image: linear-gradient(to bottom right, var(--brand-primary-gradient-start), var(--brand-primary-gradient-end));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
color: transparent;
}
.brand-secondary-gradient {
background-image: linear-gradient(to bottom right, var(--brand-secondary-gradient-start), var(--brand-secondary-gradient-end));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
color: transparent;
}
.brand-tertiary-gradient {
background-image: linear-gradient(to bottom right, var(--brand-tertiary-gradient-start), var(--brand-tertiary-gradient-end));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
color: transparent;
}
.brand-glass-gradient {
background-image: linear-gradient(to bottom right, var(--glass-gradient-start), var(--glass-gradient-end));
}
.brand-glass-hover-gradient {
background-image: linear-gradient(to bottom right, var(--glass-hover-gradient-start), var(--glass-hover-gradient-end));
}
.brand-background-gradient {
background-image: linear-gradient(to bottom right, var(--background-primary-gradient-start), var(--background-primary-gradient-end));
}
.neutral-surface-bg {
background-color: var(--neutral-surface);
}
.tina-gradient {
@apply bg-gradient-to-br from-orange-400 via-orange-500 to-orange-600 bg-clip-text text-transparent
}
mark {
background-color: var(--brand-primary-light);
font-weight: bold;
}

View File

@@ -0,0 +1,2 @@
export const blobBg =
"url('data:image/svg+xml,%3Csvg preserveAspectRatio=%27none%27 viewBox=%270 0 194 109%27 fill=%27none%27 xmlns=%27http://www.w3.org/2000/svg%27%3E%3Cg clip-path=%27url(%23clip0_566_318)%27%3E%3Crect width=%27194%27 height=%27109%27 fill=%27white%27 /%3E%3Cmask id=%27mask0_566_318%27 style=%27mask-type:alpha%27 maskUnits=%27userSpaceOnUse%27 x=%270%27 y=%270%27 width=%27194%27 height=%27109%27%3E%3Crect width=%27194%27 height=%27109%27 fill=%27url(%23paint0_linear_566_318)%27 /%3E%3C/mask%3E%3Cg mask=%27url(%23mask0_566_318)%27%3E%3Crect width=%27194%27 height=%27109%27 fill=%27url(%23paint1_linear_566_318)%27 /%3E%3C/g%3E%3C/g%3E%3Cdefs%3E%3ClinearGradient id=%27paint0_linear_566_318%27 x1=%2797%27 y1=%270%27 x2=%2797%27 y2=%27109%27 gradientUnits=%27userSpaceOnUse%27%3E%3Cstop stop-color=%27%23D9D9D9%27 stop-opacity=%270.45%27 /%3E%3Cstop offset=%270.229052%27 stop-color=%27%23D9D9D9%27 stop-opacity=%270.1678%27 /%3E%3Cstop offset=%270.677779%27 stop-color=%27%23D9D9D9%27 stop-opacity=%270.0513%27 /%3E%3Cstop offset=%271%27 stop-color=%27%23D9D9D9%27 stop-opacity=%270%27 /%3E%3C/linearGradient%3E%3ClinearGradient id=%27paint1_linear_566_318%27 x1=%270%27 y1=%2754.5%27 x2=%27194%27 y2=%2754.5%27 gradientUnits=%27userSpaceOnUse%27%3E%3Cstop stop-color=%27%2353E9DD%27 /%3E%3Cstop offset=%270.34375%27 stop-color=%27%2368D7E4%27 /%3E%3Cstop offset=%270.59375%27 stop-color=%27%2359BFF2%27 /%3E%3Cstop offset=%271%27 stop-color=%27%234BA8FF%27 /%3E%3C/linearGradient%3E%3CclipPath id=%27clip0_566_318%27%3E%3Crect width=%27194%27 height=%27109%27 fill=%27white%27 /%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E')";

View File

@@ -0,0 +1,20 @@
/**
* Detects if we're in local development mode
*/
export const detectLocalMode = (): boolean => {
// Server-side: use NODE_ENV
if (typeof window === "undefined") {
return process.env.NODE_ENV === "development";
}
// Client-side: check hostname for localhost/127.0.0.1 patterns
const hostname = window.location.hostname;
const isLocalhost =
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname.startsWith("localhost:") ||
hostname.startsWith("127.0.0.1:");
// Only consider it local if BOTH conditions are met
return process.env.NODE_ENV === "development" && isLocalhost;
};

View File

@@ -0,0 +1,23 @@
/**
* Utilities for formatting dates in document metadata
*/
/**
* Formats a date string into a human-readable format
*
* @param dateString - The date string to format (ISO format or any format accepted by Date constructor)
* @returns A formatted date string in the format "Month Day, Year"
*/
export function formatDate(dateString: string | null): string {
if (!dateString) return "";
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}

View File

@@ -0,0 +1,68 @@
/**
* Utilities for generating content excerpts
*/
interface TextNode {
type: string;
text: string;
}
interface LinkNode {
type: string;
children: TextNode[];
}
interface ParagraphNode {
type: string;
children: (TextNode | LinkNode)[];
}
interface ContentBody {
children: any[];
}
/**
* Extracts an excerpt from markdown content
* Creates a text-only summary from the first paragraphs up to the specified length
*
* @param body - The markdown body content structure
* @param excerptLength - Maximum length of the excerpt in characters
* @returns A plain text excerpt of the content
*/
export const formatExcerpt = (
body: ContentBody,
excerptLength: number
): string => {
return body.children
.filter((c) => c.type === "p")
.reduce((excerpt, child) => {
// Combine all of child's text and link nodes into a single string
const paragraphText = child.children
.filter((c) => c.type === "text" || c.type === "a")
.reduce((text, child) => {
if (child.type === "text") {
return text + (text ? " " : "") + child.text;
}
if (child.type === "a") {
return (
text +
(text ? " " : "") +
(child as LinkNode).children.map((c) => c.text).join(" ")
);
}
return text;
}, "");
// Add paragraph to excerpt with space separator
const updatedExcerpt = excerpt
? `${excerpt} ${paragraphText}`
: paragraphText;
// Truncate if the combined text is too long
if (updatedExcerpt.length > excerptLength) {
return `${updatedExcerpt.substring(0, excerptLength)}...`;
}
return updatedExcerpt;
}, "");
};

View File

@@ -0,0 +1,23 @@
/**
* Utilities for formatting and generating IDs for document elements
*/
/**
* Generates a URL-friendly ID from a document header or label text
* Used for anchor links and table of contents navigation
*
* @param label - The header text to convert to an ID
* @returns A URL-friendly string for use as an HTML ID attribute
*/
export const formatHeaderId = (label?: string): string | undefined => {
if (!label) {
return undefined;
}
return label
.toLowerCase()
.replace(/^\s*"|"\s*$/g, "-") // Replace quotes at start/end with hyphens
.replace(/^-*|-*$/g, "") // Remove leading/trailing hyphens
.replace(/\s+/g, "-") // Replace whitespace with hyphens
.replace(/[^a-z0-9\\-]/g, ""); // Remove any characters other than lowercase alphanumeric and hyphens
};

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