Skip to content

Commit 37f27c3

Browse files
committed
docs: layout redesign
1 parent 928ec38 commit 37f27c3

23 files changed

+2130
-238
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"use client";
2+
3+
import React from "react";
4+
import Link from "next/link";
5+
import { useParams, usePathname } from "next/navigation";
6+
import type { PageTree, TOCItemType } from "fumadocs-core/server";
7+
import { findNeighbour } from "fumadocs-core/server";
8+
import { useTreeContext, useTreePath } from "fumadocs-ui/provider";
9+
import * as Base from "fumadocs-core/toc";
10+
import { useActiveAnchor } from "fumadocs-core/toc";
11+
import { repoDocsPages } from "@/app/source";
12+
import { AlignmentLeft } from "../icons/alignment-left";
13+
import { ChevronLeft } from "../icons/chevron-left";
14+
import { ChevronRight } from "../icons/chevron-right";
15+
import { SidebarMenu } from "@/components/ui/sidebar";
16+
import {
17+
SidebarFolder,
18+
SidebarFolderLink,
19+
SidebarItem,
20+
SidebarFolderContent,
21+
SidebarFolderTrigger,
22+
SidebarSeparator,
23+
} from "./sidebar";
24+
25+
export const LayoutBody = ({ children }: { children: React.ReactNode }) => {
26+
return (
27+
<div
28+
id="nd-docs-layout"
29+
className="mx-auto mb-16 grid w-full max-w-screen-xl grid-cols-1 gap-x-6 md:grid-cols-[var(--sidebar-width)_minmax(0,1fr)]"
30+
>
31+
{children}
32+
</div>
33+
);
34+
};
35+
36+
function renderSidebarList(items: PageTree.Node[]): React.ReactNode[] {
37+
return items.map((item, i) => {
38+
const id = `${item.type}_${i}`;
39+
40+
switch (item.type) {
41+
case "separator":
42+
return <SidebarSeparator key={id}>{item.name}</SidebarSeparator>;
43+
case "folder":
44+
return (
45+
<PageTreeFolder key={id} item={item}>
46+
{item.index ? (
47+
<SidebarFolderLink href={item.index.url}>
48+
{item.name}
49+
</SidebarFolderLink>
50+
) : (
51+
<SidebarFolderTrigger>{item.name}</SidebarFolderTrigger>
52+
)}
53+
<SidebarFolderContent>
54+
{renderSidebarList(item.children)}
55+
</SidebarFolderContent>
56+
</PageTreeFolder>
57+
);
58+
default:
59+
return (
60+
<SidebarItem key={item.url} href={item.url}>
61+
{item.name}
62+
</SidebarItem>
63+
);
64+
}
65+
});
66+
}
67+
68+
export const SidebarItems = () => {
69+
const { root } = useTreeContext();
70+
return (
71+
<SidebarMenu className="flex flex-col gap-y-2.5">
72+
{renderSidebarList(root.children)}
73+
</SidebarMenu>
74+
);
75+
};
76+
77+
const PageTreeFolder = ({
78+
item,
79+
children,
80+
}: {
81+
item: PageTree.Folder;
82+
children: React.ReactNode;
83+
}) => {
84+
const path = useTreePath();
85+
return (
86+
<SidebarFolder defaultOpen={item.defaultOpen || path.includes(item)}>
87+
{children}
88+
</SidebarFolder>
89+
);
90+
};
91+
92+
export const Footer = () => {
93+
const { root } = useTreeContext();
94+
const pathname = usePathname();
95+
const neighbours = findNeighbour(root, pathname);
96+
return (
97+
<div className="grid w-full grid-cols-2 gap-4 md:mt-4 md:pb-6">
98+
{neighbours.previous ? (
99+
<Link
100+
className="group flex w-full flex-col items-start gap-2 text-sm no-underline opacity-100 transition-colors [&>*]:hover:text-gray-1000 [&_svg]:hover:fill-gray-1000"
101+
href={neighbours.previous.url}
102+
>
103+
<div className="flex items-center justify-center gap-x-1.5 text-gray-900 text-label-14">
104+
<ChevronLeft className="translate-y-px w-[10px] h-[10px]" />
105+
Previous
106+
</div>
107+
<span className="font-medium text-gray-1000 text-label-16">
108+
{neighbours.previous.name}
109+
</span>
110+
</Link>
111+
) : null}
112+
{neighbours.next ? (
113+
<Link
114+
className="col-start-2 flex w-full flex-col items-end gap-2 text-sm no-underline opacity-100 transition-colors [&>*]:hover:text-gray-1000 [&_svg]:hover:fill-gray-1000"
115+
href={neighbours.next.url}
116+
>
117+
<div className="flex items-center justify-center gap-x-1.5 text-gray-900 text-label-14">
118+
Next <ChevronRight className="translate-y-px w-[10px] h-[10px]" />
119+
</div>
120+
<span className="font-medium text-gray-1000 text-label-16">
121+
{neighbours.next.name}
122+
</span>
123+
</Link>
124+
) : null}
125+
</div>
126+
);
127+
};
128+
129+
function getDepthClassName(depth: number) {
130+
switch (depth) {
131+
case 3:
132+
return "pl-3";
133+
case 4:
134+
return "pl-6";
135+
case 5:
136+
return "pl-9";
137+
default:
138+
return "";
139+
}
140+
}
141+
142+
const TOCItem = ({ item }: { item: TOCItemType }) => {
143+
const activeAnchor = useActiveAnchor();
144+
const isActive = item.url.replace("#", "") === activeAnchor;
145+
146+
return (
147+
<li className={`text-sm text-gray-900 ${getDepthClassName(item.depth)}`}>
148+
<Base.TOCItem
149+
data-active={isActive}
150+
href={item.url}
151+
className="data-[active=true]:text-blue-700 dark:data-[active=true]:text-blue-600"
152+
>
153+
{item.title}
154+
</Base.TOCItem>
155+
</li>
156+
);
157+
};
158+
159+
export const TableOfContents = () => {
160+
const params = useParams<{ code: string; slug: string[] }>();
161+
const page = repoDocsPages.getPage(params.slug);
162+
if (!page) return null;
163+
const { data } = page;
164+
const ref = React.useRef<HTMLDivElement>(null);
165+
166+
return (
167+
<Base.AnchorProvider toc={data.toc}>
168+
<Base.ScrollProvider containerRef={ref}>
169+
<span className="-ms-0.5 flex items-center gap-x-1.5 text-sm font-medium text-gray-1000">
170+
<AlignmentLeft className="w-3 h-3" />
171+
On this page
172+
</span>
173+
{/* Fumadocs doesn't include title in the TOC by default, but this is too hack to keep atm */}
174+
{/* <span className="text-sm text-gray-900">{data.title}</span> */}
175+
<ul className="flex flex-col gap-y-2.5 text-sm text-gray-900">
176+
{data.toc.map((item) => {
177+
return <TOCItem key={item.url} item={item} />;
178+
})}
179+
</ul>
180+
</Base.ScrollProvider>
181+
</Base.AnchorProvider>
182+
);
183+
};
+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { TreeContextProvider } from "fumadocs-ui/provider";
2+
import { LayoutBody, SidebarItems, TableOfContents } from "./docs.client";
3+
import type { PageTree } from "fumadocs-core/server";
4+
import {
5+
Sidebar,
6+
SidebarContent,
7+
SidebarGroup,
8+
SidebarInset,
9+
} from "@/components/ui/sidebar";
10+
import { SidebarViewport } from "./sidebar";
11+
import { MobileMenu } from "./mobile-menu";
12+
import { MobileMenuProvider } from "./use-mobile-menu-context";
13+
14+
interface DocsLayoutProps {
15+
tree: PageTree.Root;
16+
children: React.ReactNode;
17+
}
18+
19+
const DocsLayout = ({ tree, children }: DocsLayoutProps) => {
20+
if (!tree) return null;
21+
return (
22+
<TreeContextProvider tree={tree}>
23+
<LayoutBody>
24+
<Sidebar className="sticky left-auto top-[calc(var(--nav-height)+32px)] h-[calc(100svh-var(--nav-height)-64px)] justify-self-end border-none">
25+
<SidebarViewport>
26+
<SidebarContent>
27+
<SidebarGroup className="px-6">
28+
<SidebarItems />
29+
</SidebarGroup>
30+
</SidebarContent>
31+
</SidebarViewport>
32+
</Sidebar>
33+
<SidebarInset>
34+
<div className="flex w-full flex-row gap-x-6 [&_article]:mt-[var(--mobile-menu-height)] md:[&_article]:mt-0 md:[&_article]:px-0 [&_h1]:mb-0 [&_h1]:!tracking-tight [&_h1]:text-heading-40">
35+
<div className="grid w-full max-w-3xl grid-cols-1 gap-10 px-0 md:pr-4 xl:mx-auto xl:px-0">
36+
<MobileMenuProvider>
37+
<MobileMenu />
38+
</MobileMenuProvider>
39+
{children}
40+
</div>
41+
<aside
42+
id="nd-toc"
43+
className="sticky top-[calc(var(--nav-height)+32px)] hidden h-fit shrink-0 flex-col gap-2.5 overflow-x-hidden p-2 md:w-[256px] xl:flex 2xl:w-72"
44+
>
45+
<TableOfContents />
46+
</aside>
47+
</div>
48+
</SidebarInset>
49+
</LayoutBody>
50+
</TreeContextProvider>
51+
);
52+
};
53+
54+
export default DocsLayout;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import { ChevronRight } from "../icons/chevron-right";
5+
import { useTreeContext } from "fumadocs-ui/provider";
6+
import type { PageTree } from "fumadocs-core/server";
7+
import {
8+
Collapsible,
9+
CollapsibleContent,
10+
CollapsibleTrigger,
11+
} from "@/components/ui/collapsible";
12+
import { itemVariants } from "./sidebar";
13+
import { cn } from "../cn";
14+
import { useLockBodyScroll } from "./use-lock-body-scroll";
15+
import { useMobileMenuContext } from "./use-mobile-menu-context";
16+
import { useEffect } from "react";
17+
import { useIsMobile } from "./use-mobile";
18+
19+
export const MobileMenu = () => {
20+
const { root } = useTreeContext();
21+
const { openMobileMenu, setOpenMobileMenu } = useMobileMenuContext();
22+
const isMobile = useIsMobile();
23+
24+
useEffect(() => {
25+
if (!isMobile) {
26+
setOpenMobileMenu(false);
27+
}
28+
}, [isMobile, setOpenMobileMenu]);
29+
30+
useLockBodyScroll(openMobileMenu);
31+
32+
return (
33+
<Collapsible
34+
className="group/collapsible absolute top-0 isolate z-10 block w-full border-b bg-background-200 px-4 text-base md:hidden"
35+
open={openMobileMenu}
36+
onOpenChange={setOpenMobileMenu}
37+
>
38+
<CollapsibleTrigger className="flex h-[var(--mobile-menu-height)] w-full items-center gap-x-2 text-gray-1000">
39+
<ChevronRight className="transition-transform group-data-[state=open]/collapsible:rotate-90 w-[14px] h-[14px]" />
40+
Menu
41+
</CollapsibleTrigger>
42+
<CollapsibleContent className="h-full">
43+
<div className="flex h-full flex-col gap-y-2.5 py-3">
44+
{renderMobileList(root.children, 1)}
45+
</div>
46+
</CollapsibleContent>
47+
</Collapsible>
48+
);
49+
};
50+
51+
const MobileMenuLink = ({ item }: { item: PageTree.Item }) => {
52+
const { setOpenMobileMenu } = useMobileMenuContext();
53+
return (
54+
<Link
55+
href={item.url}
56+
key={item.url}
57+
onClick={() => setOpenMobileMenu(false)}
58+
className={cn(
59+
itemVariants(),
60+
"text-base font-normal text-gray-900 no-underline first-of-type:mt-1 hover:text-gray-1000 [&:not(:first-of-type)]:mt-0"
61+
)}
62+
>
63+
{item.name}
64+
</Link>
65+
);
66+
};
67+
68+
export function renderMobileList(items: PageTree.Node[], level: number) {
69+
return items.map((item, i) => {
70+
const id = `${item.type}_${i}`;
71+
72+
switch (item.type) {
73+
case "separator":
74+
return (
75+
<span className={cn(itemVariants(), "text-base")} key={id}>
76+
{item.name}
77+
</span>
78+
);
79+
case "folder":
80+
return (
81+
<Collapsible key={id} className="group/folder flex flex-col gap-y-1">
82+
{item.index ? (
83+
<div className={cn(itemVariants())}>{item.name}</div>
84+
) : (
85+
<CollapsibleTrigger asChild>
86+
<button
87+
type="button"
88+
className={cn(itemVariants(), "group/trigger text-base")}
89+
>
90+
{item.name}
91+
<ChevronRight
92+
data-icon
93+
className="ml-auto transition-transform group-data-[state=open]/folder:rotate-90 w-3 h-3"
94+
/>
95+
</button>
96+
</CollapsibleTrigger>
97+
)}
98+
<CollapsibleContent>
99+
<div className="flex flex-col gap-y-2 pb-1">
100+
{renderMobileList(item.children, level + 1)}
101+
</div>
102+
</CollapsibleContent>
103+
</Collapsible>
104+
);
105+
default:
106+
return <MobileMenuLink key={id} item={item} />;
107+
}
108+
});
109+
}

0 commit comments

Comments
 (0)