initial commit after project creation
This commit is contained in:
38
src/app/api/api-schema/route.ts
Normal file
38
src/app/api/api-schema/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
20
src/app/api/export-md/route.ts
Normal file
20
src/app/api/export-md/route.ts
Normal 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}` });
|
||||
}
|
||||
31
src/app/api/exports/[...path]/route.ts
Normal file
31
src/app/api/exports/[...path]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
60
src/app/api/get-tag-api-schema/route.ts
Normal file
60
src/app/api/get-tag-api-schema/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
35
src/app/api/list-api-schemas/route.ts
Normal file
35
src/app/api/list-api-schemas/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
92
src/app/api/prepare-directory-via-filesystem/route.ts
Normal file
92
src/app/api/prepare-directory-via-filesystem/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
70
src/app/api/process-api-docs/generate-mdx-files.ts
Normal file
70
src/app/api/process-api-docs/generate-mdx-files.ts
Normal 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 };
|
||||
}
|
||||
77
src/app/api/process-api-docs/route.ts
Normal file
77
src/app/api/process-api-docs/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
14
src/app/api/process-api-docs/types.ts
Normal file
14
src/app/api/process-api-docs/types.ts
Normal 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[];
|
||||
}
|
||||
92
src/app/docs/[...slug]/index.tsx
Normal file
92
src/app/docs/[...slug]/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
src/app/docs/[...slug]/page.tsx
Normal file
114
src/app/docs/[...slug]/page.tsx
Normal 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
64
src/app/docs/page.tsx
Normal 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
62
src/app/error-wrapper.tsx
Normal 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
31
src/app/global-error.tsx
Normal 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
96
src/app/layout.tsx
Normal 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
22
src/app/not-found.tsx
Normal 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
44
src/app/tina-client.tsx
Normal 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 }} />;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
71
src/components/blocks/action-button/actions-button.tsx
Normal file
71
src/components/blocks/action-button/actions-button.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
237
src/components/copy-page-dropdown.tsx
Normal file
237
src/components/copy-page-dropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
213
src/components/docs/breadcrumbs.tsx
Normal file
213
src/components/docs/breadcrumbs.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
13
src/components/docs/layout/body.tsx
Normal file
13
src/components/docs/layout/body.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
51
src/components/docs/layout/navbar-logo.tsx
Normal file
51
src/components/docs/layout/navbar-logo.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
27
src/components/docs/layout/navigation-context.tsx
Normal file
27
src/components/docs/layout/navigation-context.tsx
Normal 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;
|
||||
};
|
||||
50
src/components/docs/layout/sidebar.tsx
Normal file
50
src/components/docs/layout/sidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
89
src/components/docs/layout/tab-layout.tsx
Normal file
89
src/components/docs/layout/tab-layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
129
src/components/docs/layout/top-nav.tsx
Normal file
129
src/components/docs/layout/top-nav.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
27
src/components/docs/layout/utils.ts
Normal file
27
src/components/docs/layout/utils.ts
Normal 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;
|
||||
};
|
||||
238
src/components/docs/on-this-page.tsx
Normal file
238
src/components/docs/on-this-page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
93
src/components/docs/table-of-contents-dropdown.tsx
Normal file
93
src/components/docs/table-of-contents-dropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
12
src/components/icons/tina-icon.svg
Normal file
12
src/components/icons/tina-icon.svg
Normal 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 |
29
src/components/navigation/constants.ts
Normal file
29
src/components/navigation/constants.ts
Normal 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",
|
||||
};
|
||||
42
src/components/navigation/mobile-navigation-sidebar.tsx
Normal file
42
src/components/navigation/mobile-navigation-sidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
6
src/components/navigation/navigation-items/index.ts
Normal file
6
src/components/navigation/navigation-items/index.ts
Normal 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";
|
||||
245
src/components/navigation/navigation-items/nav-level.tsx
Normal file
245
src/components/navigation/navigation-items/nav-level.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
51
src/components/navigation/navigation-items/nav-title.tsx
Normal file
51
src/components/navigation/navigation-items/nav-title.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
src/components/navigation/navigation-items/types.ts
Normal file
32
src/components/navigation/navigation-items/types.ts
Normal 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;
|
||||
}
|
||||
127
src/components/navigation/navigation-items/utils.ts
Normal file
127
src/components/navigation/navigation-items/utils.ts
Normal 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 };
|
||||
};
|
||||
30
src/components/navigation/navigation-sidebar.tsx
Normal file
30
src/components/navigation/navigation-sidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
150
src/components/navigation/navigation-toggle.tsx
Normal file
150
src/components/navigation/navigation-toggle.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
11
src/components/navigation/type.ts
Normal file
11
src/components/navigation/type.ts
Normal 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;
|
||||
}
|
||||
61
src/components/page-metadata/github-metadata-context.tsx
Normal file
61
src/components/page-metadata/github-metadata-context.tsx
Normal 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;
|
||||
}
|
||||
61
src/components/page-metadata/github-metadata.tsx
Normal file
61
src/components/page-metadata/github-metadata.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/components/page-metadata/index.ts
Normal file
2
src/components/page-metadata/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as GitHubMetadata } from "./github-metadata";
|
||||
export { getPreciseRelativeTime, getRelativeTime } from "./timeUtils";
|
||||
94
src/components/page-metadata/timeUtils.ts
Normal file
94
src/components/page-metadata/timeUtils.ts
Normal 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);
|
||||
}
|
||||
41
src/components/page-metadata/type.ts
Normal file
41
src/components/page-metadata/type.ts
Normal 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;
|
||||
}
|
||||
81
src/components/search-docs/search-results.tsx
Normal file
81
src/components/search-docs/search-results.tsx
Normal 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;
|
||||
}
|
||||
146
src/components/search-docs/search.tsx
Normal file
146
src/components/search-docs/search.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/components/styles/prism.tsx
Normal file
69
src/components/styles/prism.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
221
src/components/tina-markdown/embedded-elements/accordion.tsx
Normal file
221
src/components/tina-markdown/embedded-elements/accordion.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
};
|
||||
92
src/components/tina-markdown/embedded-elements/callout.tsx
Normal file
92
src/components/tina-markdown/embedded-elements/callout.tsx
Normal 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;
|
||||
71
src/components/tina-markdown/embedded-elements/card-grid.tsx
Normal file
71
src/components/tina-markdown/embedded-elements/card-grid.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
183
src/components/tina-markdown/embedded-elements/code-tabs.tsx
Normal file
183
src/components/tina-markdown/embedded-elements/code-tabs.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
355
src/components/tina-markdown/embedded-elements/recipe.tsx
Normal file
355
src/components/tina-markdown/embedded-elements/recipe.tsx
Normal 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;
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
33
src/components/tina-markdown/embedded-elements/youtube.tsx
Normal file
33
src/components/tina-markdown/embedded-elements/youtube.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
src/components/tina-markdown/markdown-component-mapping.tsx
Normal file
180
src/components/tina-markdown/markdown-component-mapping.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
58
src/components/tina-markdown/standard-elements/image.tsx
Normal file
58
src/components/tina-markdown/standard-elements/image.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
56
src/components/tina-markdown/standard-elements/table.tsx
Normal file
56
src/components/tina-markdown/standard-elements/table.tsx
Normal 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;
|
||||
47
src/components/ui/admin-link.tsx
Normal file
47
src/components/ui/admin-link.tsx
Normal 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;
|
||||
130
src/components/ui/buttons.tsx
Normal file
130
src/components/ui/buttons.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
60
src/components/ui/custom-color-toggle.tsx
Normal file
60
src/components/ui/custom-color-toggle.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
91
src/components/ui/custom-dropdown.tsx
Normal file
91
src/components/ui/custom-dropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
27
src/components/ui/dynamic-link.tsx
Normal file
27
src/components/ui/dynamic-link.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
166
src/components/ui/image-overlay-wrapper.tsx
Normal file
166
src/components/ui/image-overlay-wrapper.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
37
src/components/ui/light-dark-switch.tsx
Normal file
37
src/components/ui/light-dark-switch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/components/ui/pagination.tsx
Normal file
127
src/components/ui/pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/components/ui/tailwind-indicator.tsx
Normal file
14
src/components/ui/tailwind-indicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
src/components/ui/theme-selector.tsx
Normal file
180
src/components/ui/theme-selector.tsx
Normal 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
63
src/constants/index.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
21
src/hooks/screen-resizer.tsx
Normal file
21
src/hooks/screen-resizer.tsx
Normal 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 };
|
||||
}
|
||||
31
src/services/tina/fetch-tina-data.ts
Normal file
31
src/services/tina/fetch-tina-data.ts
Normal 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
583
src/styles/global.css
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
2
src/utils/backgrounds/svgs.ts
Normal file
2
src/utils/backgrounds/svgs.ts
Normal 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')";
|
||||
20
src/utils/detectLocalMode.tsx
Normal file
20
src/utils/detectLocalMode.tsx
Normal 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;
|
||||
};
|
||||
23
src/utils/docs/formatting/formatDate.ts
Normal file
23
src/utils/docs/formatting/formatDate.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
68
src/utils/docs/formatting/formatExcerpt.ts
Normal file
68
src/utils/docs/formatting/formatExcerpt.ts
Normal 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;
|
||||
}, "");
|
||||
};
|
||||
23
src/utils/docs/formatting/formatIds.ts
Normal file
23
src/utils/docs/formatting/formatIds.ts
Normal 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
Reference in New Issue
Block a user