This commit is contained in:
giancarlo
2024-03-24 02:23:22 +08:00
parent 648d77b430
commit bce3479368
589 changed files with 37067 additions and 9596 deletions

View File

@@ -0,0 +1,101 @@
import { cache } from 'react';
import { notFound } from 'next/navigation';
import { allDocumentationPages } from 'contentlayer/generated';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { SitePageHeader } from '~/(marketing)/components/site-page-header';
import { DocsCards } from '~/(marketing)/docs/components/docs-cards';
import { DocumentationPageLink } from '~/(marketing)/docs/components/documentation-page-link';
import { getDocumentationPageTree } from '~/(marketing)/docs/utils/get-documentation-page-tree';
import { withI18n } from '~/lib/i18n/with-i18n';
import { If } from '@kit/ui/if';
import { Mdx } from '@kit/ui/mdx';
const getPageBySlug = cache((slug: string) => {
return allDocumentationPages.find((post) => post.resolvedPath === slug);
});
interface PageParams {
params: {
slug: string[];
};
}
export const generateMetadata = ({ params }: PageParams) => {
const page = getPageBySlug(params.slug.join('/'));
if (!page) {
notFound();
}
const { title, description } = page;
return {
title,
description,
};
};
function DocumentationPage({ params }: PageParams) {
const page = getPageBySlug(params.slug.join('/'));
if (!page) {
notFound();
}
const { nextPage, previousPage, children } =
getDocumentationPageTree(page.resolvedPath) ?? {};
const description = page?.description ?? '';
return (
<div className={'container mx-auto'}>
<div className={'relative flex grow flex-col space-y-4 px-8 py-8'}>
<SitePageHeader
title={page.title}
subtitle={description}
className={'items-start'}
/>
<Mdx code={page.body.code} />
<If condition={children}>
<DocsCards pages={children ?? []} />
</If>
<div
className={
'flex flex-col justify-between space-y-4 md:flex-row md:space-x-8' +
' md:space-y-0'
}
>
<div className={'w-full'}>
<If condition={previousPage}>
{(page) => (
<DocumentationPageLink
page={page}
before={<ChevronLeftIcon className={'w-4'} />}
/>
)}
</If>
</div>
<div className={'w-full'}>
<If condition={nextPage}>
{(page) => (
<DocumentationPageLink
page={page}
after={<ChevronRightIcon className={'w-4'} />}
/>
)}
</If>
</div>
</div>
</div>
</div>
);
}
export default withI18n(DocumentationPage);

View File

@@ -0,0 +1,45 @@
import Link from 'next/link';
import { ChevronRightIcon } from 'lucide-react';
export const DocsCard: React.FC<
React.PropsWithChildren<{
label: string;
subtitle?: string | null;
link?: { url: string; label: string };
}>
> = ({ label, subtitle, children, link }) => {
return (
<div className="flex flex-col">
<div
className={`flex grow flex-col space-y-2.5 border bg-background p-6
${link ? 'rounded-t-2xl border-b-0' : 'rounded-2xl'}`}
>
<h3 className="mt-0 text-lg font-semibold dark:text-white">{label}</h3>
{subtitle && (
<div className="text-sm text-gray-500 dark:text-gray-400">
<p>{subtitle}</p>
</div>
)}
{children && <div className="text-sm">{children}</div>}
</div>
{link && (
<div className="rounded-b-2xl border bg-muted p-6 py-4 dark:bg-background">
<span className={'flex items-center space-x-2'}>
<Link
className={'text-sm font-medium hover:underline'}
href={`/docs/${link.url}`}
>
{link.label}
</Link>
<ChevronRightIcon className={'h-4'} />
</span>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,23 @@
import type { DocumentationPage } from 'contentlayer/generated';
import { DocsCard } from './docs-card';
export function DocsCards({ pages }: { pages: DocumentationPage[] }) {
return (
<div className={'grid grid-cols-1 gap-8 lg:grid-cols-2'}>
{pages.map((item) => {
return (
<DocsCard
key={item.label}
label={item.label}
subtitle={item.description}
link={{
url: item.resolvedPath,
label: item.cardCTA ?? 'Read more',
}}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,225 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ChevronDownIcon, MenuIcon } from 'lucide-react';
import { isBrowser } from '@kit/shared/utils';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import { cn } from '@kit/ui/utils';
import type { ProcessedDocumentationPage } from '../utils/build-documentation-tree';
const DocsNavLink: React.FC<{
label: string;
url: string;
level: number;
activePath: string;
collapsible: boolean;
collapsed: boolean;
toggleCollapsed: () => void;
}> = ({
label,
url,
level,
activePath,
collapsible,
collapsed,
toggleCollapsed,
}) => {
const isCurrent = url == activePath;
const isFirstLevel = level === 0;
return (
<div className={getNavLinkClassName(isCurrent, isFirstLevel)}>
<Link
className="flex h-full max-w-full grow items-center space-x-2"
href={`/docs/${url}`}
>
<span className="block max-w-full truncate">{label}</span>
</Link>
{collapsible && (
<button
aria-label="Toggle children"
onClick={toggleCollapsed}
className="mr-2 shrink-0 px-2 py-1"
>
<span
className={`block w-2.5 ${collapsed ? '-rotate-90 transform' : ''}`}
>
<ChevronDownIcon className="h-4 w-4" />
</span>
</button>
)}
</div>
);
};
const Node: React.FC<{
node: ProcessedDocumentationPage;
level: number;
activePath: string;
}> = ({ node, level, activePath }) => {
const [collapsed, setCollapsed] = useState<boolean>(node.collapsed ?? false);
const toggleCollapsed = () => setCollapsed(!collapsed);
useEffect(() => {
if (
activePath == node.resolvedPath ||
node.children.map((_) => _.resolvedPath).includes(activePath)
) {
setCollapsed(false);
}
}, [activePath, node.children, node.resolvedPath]);
return (
<>
<DocsNavLink
label={node.label}
url={node.resolvedPath}
level={level}
activePath={activePath}
collapsible={node.collapsible}
collapsed={collapsed}
toggleCollapsed={toggleCollapsed}
/>
{node.children.length > 0 && !collapsed && (
<Tree tree={node.children} level={level + 1} activePath={activePath} />
)}
</>
);
};
function Tree({
tree,
level,
activePath,
}: {
tree: ProcessedDocumentationPage[];
level: number;
activePath: string;
}) {
return (
<div className={cn('w-full space-y-2.5 pl-3', level > 0 ? 'border-l' : '')}>
{tree.map((treeNode, index) => (
<Node
key={index}
node={treeNode}
level={level}
activePath={activePath}
/>
))}
</div>
);
}
export default function DocsNavigation({
tree,
}: {
tree: ProcessedDocumentationPage[];
}) {
const activePath = usePathname().replace('/docs/', '');
return (
<>
<aside
style={{
height: `calc(100vh - 64px)`,
}}
className="sticky top-2 hidden w-80 shrink-0 border-r p-4 lg:flex"
>
<Tree tree={tree} level={0} activePath={activePath} />
</aside>
<div className={'lg:hidden'}>
<FloatingDocumentationNavigation tree={tree} activePath={activePath} />
</div>
</>
);
}
function getNavLinkClassName(isCurrent: boolean, isFirstLevel: boolean) {
return cn(
'group flex h-8 items-center justify-between space-x-2 whitespace-nowrap rounded-md px-3 text-sm leading-none transition-colors',
{
[`bg-muted`]: isCurrent,
[`hover:bg-muted`]: !isCurrent,
[`font-semibold`]: isFirstLevel,
[`font-normal`]: !isFirstLevel && isCurrent,
[`hover:text-foreground-muted`]: !isFirstLevel && !isCurrent,
},
);
}
function FloatingDocumentationNavigation({
tree,
activePath,
}: React.PropsWithChildren<{
tree: ProcessedDocumentationPage[];
activePath: string;
}>) {
const body = useMemo(() => {
return isBrowser() ? document.body : null;
}, []);
const [isVisible, setIsVisible] = useState(false);
const enableScrolling = (element: HTMLElement) =>
(element.style.overflowY = '');
const disableScrolling = (element: HTMLElement) =>
(element.style.overflowY = 'hidden');
// enable/disable body scrolling when the docs are toggled
useEffect(() => {
if (!body) {
return;
}
if (isVisible) {
disableScrolling(body);
} else {
enableScrolling(body);
}
}, [isVisible, body]);
// hide docs when navigating to another page
useEffect(() => {
setIsVisible(false);
}, [activePath]);
const onClick = () => {
setIsVisible(!isVisible);
};
return (
<>
<If condition={isVisible}>
<div
className={
'fixed left-0 top-0 z-10 h-screen w-full p-4' +
' flex flex-col space-y-4 overflow-auto bg-white dark:bg-background'
}
>
<Heading level={1}>Table of Contents</Heading>
<Tree tree={tree} level={0} activePath={activePath} />
</div>
</If>
<Button
className={'fixed bottom-5 right-5 z-10 h-16 w-16 rounded-full'}
onClick={onClick}
>
<MenuIcon className={'h-8'} />
</Button>
</>
);
}

View File

@@ -0,0 +1,47 @@
import Link from 'next/link';
import type { DocumentationPage } from 'contentlayer/generated';
import { If } from '@kit/ui/if';
import { cn } from '@kit/ui/utils';
export function DocumentationPageLink({
page,
before,
after,
}: React.PropsWithChildren<{
page: DocumentationPage;
before?: React.ReactNode;
after?: React.ReactNode;
}>) {
return (
<Link
className={cn(
`flex w-full items-center space-x-8 rounded-xl p-6 font-medium text-current ring-2 ring-muted transition-all hover:ring-primary`,
{
'justify-start': before,
'justify-end self-end': after,
},
)}
href={`/docs/${page.resolvedPath}`}
>
<If condition={before}>{(node) => <>{node}</>}</If>
<span className={'flex flex-col space-y-1.5'}>
<span
className={
'text-xs font-semibold uppercase dark:text-gray-400' +
' text-gray-500'
}
>
{before ? `Previous` : ``}
{after ? `Next` : ``}
</span>
<span>{page.title}</span>
</span>
<If condition={after}>{(node) => <>{node}</>}</If>
</Link>
);
}

View File

@@ -0,0 +1,21 @@
import type { DocumentationPage } from 'contentlayer/generated';
import { allDocumentationPages } from 'contentlayer/generated';
import DocsNavigation from './components/docs-navigation';
import { buildDocumentationTree } from './utils/build-documentation-tree';
function DocsLayout({ children }: React.PropsWithChildren) {
const tree = buildDocumentationTree(allDocumentationPages);
return (
<div className={'container mx-auto'}>
<div className={'flex'}>
<DocsNavigation tree={tree} />
<div className={'flex w-full flex-col items-center'}>{children}</div>
</div>
</div>
);
}
export default DocsLayout;

View File

@@ -0,0 +1,30 @@
import { allDocumentationPages } from 'contentlayer/generated';
import appConfig from '~/config/app.config';
import { withI18n } from '~/lib/i18n/with-i18n';
import { SitePageHeader } from '../components/site-page-header';
import { DocsCards } from './components/docs-cards';
import { buildDocumentationTree } from './utils/build-documentation-tree';
export const metadata = {
title: `Documentation - ${appConfig.name}`,
};
function DocsPage() {
const tree = buildDocumentationTree(allDocumentationPages);
return (
<div className={'my-8 flex flex-col space-y-16'}>
<SitePageHeader
title={'Documentation'}
subtitle={'Get started with our guides and tutorials'}
/>
<div>
<DocsCards pages={tree ?? []} />
</div>
</div>
);
}
export default withI18n(DocsPage);

View File

@@ -0,0 +1,53 @@
import { cache } from 'react';
import type { DocumentationPage } from 'contentlayer/generated';
export interface ProcessedDocumentationPage extends DocumentationPage {
collapsible: boolean;
pathSegments: string[];
nextPage: ProcessedDocumentationPage | DocumentationPage | null;
previousPage: ProcessedDocumentationPage | DocumentationPage | null;
children: DocsTree;
}
export type DocsTree = ProcessedDocumentationPage[];
/**
* Build a tree of documentation pages from a flat list of pages with path segments
* @param docs
* @param parentPathNames
*/
export const buildDocumentationTree = cache(
(docs: DocumentationPage[], parentPathNames: string[] = []): DocsTree => {
const level = parentPathNames.length;
const pages = docs
.filter(
(_) =>
_.pathSegments.length === level + 1 &&
_.pathSegments
.map(({ pathName }: { pathName: string }) => pathName)
.join('/')
.startsWith(parentPathNames.join('/')),
)
.sort(
(a, b) => a.pathSegments[level].order - b.pathSegments[level].order,
);
return pages.map((doc, index) => {
const children = buildDocumentationTree(
docs,
doc.pathSegments.map(({ pathName }: { pathName: string }) => pathName),
);
return {
...doc,
pathSegments: doc.pathSegments || ([] as string[]),
collapsible: children.length > 0,
nextPage: children[0] || pages[index + 1],
previousPage: pages[index - 1],
children,
};
});
},
);

View File

@@ -0,0 +1,45 @@
import { cache } from 'react';
import type { DocumentationPage } from 'contentlayer/generated';
import { allDocumentationPages } from 'contentlayer/generated';
import { buildDocumentationTree } from './build-documentation-tree';
/**
* Retrieves a specific documentation page from the page tree by its path.
*
* @param {string} pagePath - The path of the documentation page to retrieve.
* @returns {DocumentationPageWithChildren | undefined} The documentation page found in the tree, if any.
*/
export const getDocumentationPageTree = cache((pagePath: string) => {
const tree = buildDocumentationTree(allDocumentationPages);
type DocumentationPageWithChildren = DocumentationPage & {
previousPage?: DocumentationPage | null;
nextPage?: DocumentationPage | null;
children?: DocumentationPage[];
};
const findPageInTree = (
pages: DocumentationPageWithChildren[],
path: string,
): DocumentationPageWithChildren | undefined => {
for (const page of pages) {
if (page.resolvedPath === path) {
return page;
}
const hasChildren = page.children && page.children.length > 0;
if (hasChildren) {
const foundPage = findPageInTree(page.children ?? [], path);
if (foundPage) {
return foundPage;
}
}
}
};
return findPageInTree(tree, pagePath);
});