Unify workspace dropdowns; Update layouts (#458)

Unified Account and Workspace drop-downs; Layout updates, now header lives within the PageBody component; Sidebars now use floating variant
This commit is contained in:
Giancarlo Buomprisco
2026-03-11 14:45:42 +08:00
committed by GitHub
parent ca585e09be
commit 4bc8448a1d
530 changed files with 14398 additions and 11198 deletions

View File

@@ -0,0 +1,92 @@
import { cache } from 'react';
import { notFound } from 'next/navigation';
import { ContentRenderer, createCmsClient } from '@kit/cms';
import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator';
import { cn } from '@kit/ui/utils';
// local imports
import { DocsCards } from '../_components/docs-cards';
const getPageBySlug = cache(pageLoader);
interface DocumentationPageProps {
params: Promise<{ slug: string[] }>;
}
async function pageLoader(slug: string) {
const client = await createCmsClient();
return client.getContentItemBySlug({ slug, collection: 'documentation' });
}
export const generateMetadata = async ({ params }: DocumentationPageProps) => {
const slug = (await params).slug.join('/');
const page = await getPageBySlug(slug);
if (!page) {
notFound();
}
const { title, description } = page;
return {
title,
description,
};
};
async function DocumentationPage({ params }: DocumentationPageProps) {
const slug = (await params).slug.join('/');
const page = await getPageBySlug(slug);
if (!page) {
notFound();
}
const description = page?.description ?? '';
return (
<div className={'flex flex-1 flex-col gap-y-4 overflow-y-hidden'}>
<div className={'flex size-full overflow-y-hidden'}>
<div className="relative size-full">
<article
className={cn(
'absolute size-full w-full gap-y-12 overflow-y-auto pt-4 pb-36',
)}
>
<section
className={'flex flex-col gap-y-1 border-b border-dashed pb-4'}
>
<h1
className={
'text-foreground text-3xl font-semibold tracking-tighter'
}
>
{page.title}
</h1>
<h2 className={'text-secondary-foreground/80 text-lg'}>
{description}
</h2>
</section>
<div className={'markdoc'}>
<ContentRenderer content={page.content} />
</div>
</article>
</div>
</div>
<If condition={page.children.length > 0}>
<Separator />
<DocsCards cards={page.children ?? []} />
</If>
</div>
);
}
export default DocumentationPage;

View File

@@ -0,0 +1,32 @@
import Link from 'next/link';
export function DocsCard({
title,
subtitle,
children,
link,
}: React.PropsWithChildren<{
title: string;
subtitle?: string | null;
link: { url: string; label?: string };
}>) {
return (
<Link href={link.url} className="flex flex-col">
<div
className={`hover:bg-muted/70 flex grow flex-col gap-y-0.5 rounded border p-4`}
>
<h3 className="mt-0 text-lg font-medium hover:underline dark:text-white">
{title}
</h3>
{subtitle && (
<div className="text-muted-foreground text-sm">
<p dangerouslySetInnerHTML={{ __html: subtitle }}></p>
</div>
)}
{children && <div className="text-sm">{children}</div>}
</div>
</Link>
);
}

View File

@@ -0,0 +1,24 @@
import { Cms } from '@kit/cms';
import { DocsCard } from './docs-card';
export function DocsCards({ cards }: { cards: Cms.ContentItem[] }) {
const cardsSortedByOrder = [...cards].sort((a, b) => a.order - b.order);
return (
<div className={'absolute flex w-full flex-col gap-4 pb-48 lg:max-w-2xl'}>
{cardsSortedByOrder.map((item) => {
return (
<DocsCard
key={item.title}
title={item.title}
subtitle={item.description}
link={{
url: `/docs/${item.slug}`,
}}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,30 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { SidebarMenuButton, SidebarMenuItem } from '@kit/ui/sidebar';
import { cn, isRouteActive } from '@kit/ui/utils';
export function DocsNavLink({
label,
url,
children,
}: React.PropsWithChildren<{ label: string; url: string }>) {
const currentPath = usePathname();
const isCurrent = isRouteActive(url, currentPath);
return (
<SidebarMenuItem>
<SidebarMenuButton
render={<Link href={url} />}
isActive={isCurrent}
className={cn('text-secondary-foreground transition-all')}
>
<span className="block max-w-full truncate">{label}</span>
{children}
</SidebarMenuButton>
</SidebarMenuItem>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import { usePathname } from 'next/navigation';
import { Cms } from '@kit/cms';
import { Collapsible } from '@kit/ui/collapsible';
import { cn, isRouteActive } from '@kit/ui/utils';
export function DocsNavigationCollapsible(
props: React.PropsWithChildren<{
node: Cms.ContentItem;
prefix: string;
}>,
) {
const currentPath = usePathname();
const prefix = props.prefix;
const isChildActive = props.node.children.some((child) =>
isRouteActive(prefix + '/' + child.url, currentPath),
);
return (
<Collapsible
className={cn('group/collapsible', {
'group/active': isChildActive,
})}
defaultOpen={isChildActive ? true : !props.node.collapsed}
>
{props.children}
</Collapsible>
);
}

View File

@@ -0,0 +1,153 @@
import { ChevronDown } from 'lucide-react';
import { Cms } from '@kit/cms';
import { CollapsibleContent, CollapsibleTrigger } from '@kit/ui/collapsible';
import {
Sidebar,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
} from '@kit/ui/sidebar';
import { DocsNavLink } from '~/(marketing)/docs/_components/docs-nav-link';
import { DocsNavigationCollapsible } from '~/(marketing)/docs/_components/docs-navigation-collapsible';
import { FloatingDocumentationNavigationButton } from './floating-docs-navigation-button';
function Node({
node,
level,
prefix,
}: {
node: Cms.ContentItem;
level: number;
prefix: string;
}) {
const url = `${prefix}/${node.slug}`;
const label = node.label ? node.label : node.title;
return (
<NodeContainer node={node} prefix={prefix}>
<NodeTrigger node={node} label={label} url={url} />
<NodeContentContainer node={node}>
<Tree pages={node.children ?? []} level={level + 1} prefix={prefix} />
</NodeContentContainer>
</NodeContainer>
);
}
function NodeContentContainer({
node,
children,
}: {
node: Cms.ContentItem;
children: React.ReactNode;
}) {
if (node.collapsible) {
return <CollapsibleContent>{children}</CollapsibleContent>;
}
return children;
}
function NodeContainer({
node,
prefix,
children,
}: {
node: Cms.ContentItem;
prefix: string;
children: React.ReactNode;
}) {
if (node.collapsible) {
return (
<DocsNavigationCollapsible node={node} prefix={prefix}>
{children}
</DocsNavigationCollapsible>
);
}
return children;
}
function NodeTrigger({
node,
label,
url,
}: {
node: Cms.ContentItem;
label: string;
url: string;
}) {
if (node.collapsible) {
return (
<CollapsibleTrigger render={<SidebarMenuItem />}>
<SidebarMenuButton>
{label}
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
</SidebarMenuButton>
</CollapsibleTrigger>
);
}
return <DocsNavLink label={label} url={url} />;
}
function Tree({
pages,
level,
prefix,
}: {
pages: Cms.ContentItem[];
level: number;
prefix: string;
}) {
if (level === 0) {
return pages.map((treeNode, index) => (
<Node key={index} node={treeNode} level={level} prefix={prefix} />
));
}
if (pages.length === 0) {
return null;
}
return (
<SidebarMenuSub>
{pages.map((treeNode, index) => (
<Node key={index} node={treeNode} level={level} prefix={prefix} />
))}
</SidebarMenuSub>
);
}
export function DocsNavigation({
pages,
prefix = '/docs',
}: {
pages: Cms.ContentItem[];
prefix?: string;
}) {
return (
<>
<Sidebar
variant={'sidebar'}
className={'sticky z-1 mt-4 max-h-full overflow-y-auto pr-4'}
>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu className={'pb-48'}>
<Tree pages={pages} level={0} prefix={prefix} />
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</Sidebar>
<FloatingDocumentationNavigationButton />
</>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import { Menu } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { useSidebar } from '@kit/ui/sidebar';
export function FloatingDocumentationNavigationButton() {
const { toggleSidebar } = useSidebar();
return (
<Button
size="custom"
variant="custom"
className={
'bg-primary fixed right-5 bottom-5 z-10 h-16! w-16! rounded-full! lg:hidden'
}
onClick={toggleSidebar}
>
<Menu className={'text-primary-foreground size-6'} />
</Button>
);
}

View File

@@ -0,0 +1,31 @@
import { cache } from 'react';
import { createCmsClient } from '@kit/cms';
import { getLogger } from '@kit/shared/logger';
/**
* @name getDocs
* @description Load the documentation pages.
* @param language
*/
export const getDocs = cache(docsLoader);
async function docsLoader(language: string | undefined) {
const cms = await createCmsClient();
const logger = await getLogger();
try {
const data = await cms.getContentItems({
collection: 'documentation',
language,
limit: Infinity,
content: false,
});
return data.items;
} catch (error) {
logger.error({ error }, 'Failed to load documentation pages');
return [];
}
}

View File

@@ -0,0 +1,33 @@
import { Cms } from '@kit/cms';
/**
* @name buildDocumentationTree
* @description Build a tree structure for the documentation pages.
* @param pages
*/
export function buildDocumentationTree(pages: Cms.ContentItem[]) {
const tree: Cms.ContentItem[] = [];
pages.forEach((page) => {
if (page.parentId) {
const parent = pages.find((item) => item.slug === page.parentId);
if (!parent) {
return;
}
if (!parent.children) {
parent.children = [];
}
parent.children.push(page);
// sort children by order
parent.children.sort((a, b) => a.order - b.order);
} else {
tree.push(page);
}
});
return tree.sort((a, b) => a.order - b.order);
}

View File

@@ -0,0 +1,45 @@
import { getLocale } from 'next-intl/server';
import { SidebarProvider } from '@kit/ui/sidebar';
// local imports
import { DocsNavigation } from './_components/docs-navigation';
import { getDocs } from './_lib/server/docs.loader';
import { buildDocumentationTree } from './_lib/utils';
async function DocsLayout({ children }: React.PropsWithChildren) {
const locale = await getLocale();
const docs = await getDocs(locale);
const tree = buildDocumentationTree(docs);
return (
<div className={'container h-[calc(100vh-56px)] overflow-y-hidden'}>
<SidebarProvider
className="lg:gap-x-6"
style={{ '--sidebar-width': '17em' } as React.CSSProperties}
>
<HideFooterStyles />
<DocsNavigation pages={tree} />
{children}
</SidebarProvider>
</div>
);
}
function HideFooterStyles() {
return (
<style
dangerouslySetInnerHTML={{
__html: `
.site-footer {
display: none;
}
`,
}}
/>
);
}
export default DocsLayout;

View File

@@ -0,0 +1,37 @@
import { getLocale, getTranslations } from 'next-intl/server';
import { SitePageHeader } from '../_components/site-page-header';
import { DocsCards } from './_components/docs-cards';
import { getDocs } from './_lib/server/docs.loader';
export const generateMetadata = async () => {
const t = await getTranslations('marketing');
return {
title: t('documentation'),
};
};
async function DocsPage() {
const t = await getTranslations('marketing');
const locale = await getLocale();
const items = await getDocs(locale);
// Filter out any docs that have a parentId, as these are children of other docs
const cards = items.filter((item) => !item.parentId);
return (
<div className={'flex w-full flex-1 flex-col gap-y-6 xl:gap-y-8'}>
<SitePageHeader
title={t('documentation')}
subtitle={t('documentationSubtitle')}
/>
<div className={'relative flex size-full justify-center overflow-y-auto'}>
<DocsCards cards={cards} />
</div>
</div>
);
}
export default DocsPage;