mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
UI: Cohesive page layout
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
---
|
||||
'fumadocs-ui': minor
|
||||
---
|
||||
|
||||
**Require importing page layout according to your docs layout:**
|
||||
|
||||
```ts
|
||||
// for docs layout
|
||||
import { DocsPage } from 'fumadocs-ui/layouts/docs/page';
|
||||
|
||||
// for notebook layout
|
||||
import { DocsPage } from 'fumadocs-ui/layouts/notebook/page';
|
||||
```
|
||||
|
||||
The default `fumadocs-ui/page` will redirect to `fumadocs-ui/layouts/docs/page`, if you're on Notebook layout, please update the import.
|
||||
@@ -82,7 +82,7 @@ export function Hero() {
|
||||
size={3}
|
||||
speed={0}
|
||||
frame={5000 * 120}
|
||||
className="absolute max-lg:bottom-[-50%] max-lg:left-[-200px] animate-fd-fade-in duration-400 lg:top-[-5%] lg:right-0"
|
||||
className="absolute animate-fd-fade-in duration-400 max-lg:bottom-[-50%] max-lg:left-[-200px] lg:top-[-5%] lg:right-0"
|
||||
minPixelRatio={1}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type LinkItemType } from 'fumadocs-ui/layouts/docs';
|
||||
import { AlbumIcon, Heart, LayoutTemplate } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
import type { BaseLayoutProps, LinkItemType } from 'fumadocs-ui/layouts/shared';
|
||||
import { FumadocsIcon } from '@/app/layout.client';
|
||||
import Logo from '@/public/logo.png';
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
@source '../../dist/layouts/docs/**/*.js';
|
||||
|
||||
#nd-docs-layout {
|
||||
--fd-sidebar-width: 0px;
|
||||
--fd-toc-width: 0px;
|
||||
--fd-layout-width: 1600px;
|
||||
|
||||
--fd-docs-toc-top: 0px;
|
||||
--fd-docs-toc-popover-top: calc(14 * var(--spacing));
|
||||
--fd-docs-sidebar-top: 0px;
|
||||
--fd-docs-nav-top: 0px;
|
||||
--fd-docs-sidebar-mobile-offset: 100%;
|
||||
}
|
||||
|
||||
[dir='rtl'] #nd-docs-layout {
|
||||
--fd-docs-sidebar-mobile-offset: -100%;
|
||||
}
|
||||
|
||||
@variant md {
|
||||
#nd-docs-layout {
|
||||
--fd-sidebar-width: 268px;
|
||||
--fd-docs-toc-popover-top: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@variant xl {
|
||||
#nd-docs-layout {
|
||||
--fd-toc-width: 268px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@source '../../dist/layouts/home/**/*.js';
|
||||
@@ -0,0 +1,32 @@
|
||||
@source '../../dist/layouts/notebook/**/*.js';
|
||||
|
||||
@layer base {
|
||||
#nd-notebook-layout {
|
||||
--fd-page-width: 1200px;
|
||||
--fd-sidebar-width: 0px;
|
||||
--fd-toc-width: 0px;
|
||||
--fd-layout-width: 1600px;
|
||||
--fd-banner-height: 0px;
|
||||
--fd-nav-height: 0px;
|
||||
--fd-tocnav-height: 0px;
|
||||
--fd-sidebar-mobile-offset: 100%;
|
||||
--fd-layout-offset: max(calc(50vw - var(--fd-layout-width) / 2), 0px);
|
||||
}
|
||||
|
||||
[dir='rtl'] #nd-notebook-layout {
|
||||
--fd-sidebar-mobile-offset: -100%;
|
||||
}
|
||||
|
||||
@variant xl {
|
||||
#nd-notebook-layout {
|
||||
--fd-toc-width: 286px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Apply styles to #nd-notebook-layout */
|
||||
@custom-variant on-notebook-layout {
|
||||
#nd-notebook-layout:has(&) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
+12
-26
@@ -1,22 +1,20 @@
|
||||
@import './shiki.css';
|
||||
@import './components/image-zoom.css';
|
||||
|
||||
/* TODO: remove them on next major */
|
||||
@import './layouts/docs.css';
|
||||
@import './layouts/home.css';
|
||||
@import './layouts/notebook.css';
|
||||
|
||||
@source '../dist/*.js';
|
||||
@source '../dist/{components,contexts,provider,utils}/**/*.js';
|
||||
@source '../dist/layouts/shared/**/*.js';
|
||||
|
||||
@source '../dist/**/*.js';
|
||||
@plugin '../dist/theme/typography/index.js';
|
||||
|
||||
[dir='rtl'] {
|
||||
--fd-sidebar-mobile-offset: -100%;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--fd-sidebar-mobile-offset: 100%;
|
||||
--spacing-fd-container: 1400px;
|
||||
--fd-page-width: 1200px;
|
||||
--fd-sidebar-width: 0px;
|
||||
--fd-toc-width: 0px;
|
||||
--fd-layout-width: 1600px;
|
||||
--fd-banner-height: 0px;
|
||||
--fd-nav-height: 0px;
|
||||
--fd-tocnav-height: 0px;
|
||||
|
||||
--animate-fd-fade-in: fd-fade-in 300ms ease;
|
||||
--animate-fd-fade-out: fd-fade-out 300ms ease;
|
||||
|
||||
@@ -230,11 +228,6 @@
|
||||
background-color: var(--color-fd-background);
|
||||
color: var(--color-fd-foreground);
|
||||
}
|
||||
|
||||
:root,
|
||||
#nd-docs-layout {
|
||||
--fd-layout-offset: max(calc(50vw - var(--fd-layout-width) / 2), 0px);
|
||||
}
|
||||
}
|
||||
|
||||
@utility fd-scroll-container {
|
||||
@@ -299,14 +292,7 @@
|
||||
margin-inline: auto;
|
||||
padding-inline: 1rem;
|
||||
|
||||
@apply 2xl:max-w-(--spacing-fd-container);
|
||||
@apply 2xl:max-w-fd-container;
|
||||
}
|
||||
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Apply styles to :root */
|
||||
@custom-variant on-root {
|
||||
:root:has(&) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,11 @@
|
||||
border: none;
|
||||
}
|
||||
|
||||
.fd-default-layout #nd-page article {
|
||||
#nd-docs-layout #nd-page article {
|
||||
@apply mx-4;
|
||||
}
|
||||
|
||||
.fd-notebook-layout #nd-page article {
|
||||
#nd-notebook-layout #nd-page article {
|
||||
@apply mx-6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,33 +37,24 @@
|
||||
"types": "./dist/layouts/docs/index.d.ts"
|
||||
},
|
||||
"./layouts/docs/page": {
|
||||
"import": "./dist/layouts/docs/page.js",
|
||||
"types": "./dist/layouts/docs/page.d.ts"
|
||||
},
|
||||
"./layouts/docs-client": {
|
||||
"import": "./dist/layouts/docs/client.js",
|
||||
"types": "./dist/layouts/docs/client.d.ts"
|
||||
"import": "./dist/layouts/docs/page/index.js",
|
||||
"types": "./dist/layouts/docs/page/index.d.ts"
|
||||
},
|
||||
"./layouts/notebook": {
|
||||
"import": "./dist/layouts/notebook/index.js",
|
||||
"types": "./dist/layouts/notebook/index.d.ts"
|
||||
},
|
||||
"./layouts/notebook-client": {
|
||||
"import": "./dist/layouts/notebook/client.js",
|
||||
"types": "./dist/layouts/notebook/client.d.ts"
|
||||
},
|
||||
"./layouts/links": {
|
||||
"import": "./dist/layouts/shared/index.js",
|
||||
"types": "./dist/layouts/shared/index.d.ts"
|
||||
"./layouts/notebook/page": {
|
||||
"import": "./dist/layouts/notebook/page/index.js",
|
||||
"types": "./dist/layouts/notebook/page/index.d.ts"
|
||||
},
|
||||
"./layouts/shared": {
|
||||
"import": "./dist/layouts/shared/index.js",
|
||||
"types": "./dist/layouts/shared/index.d.ts"
|
||||
},
|
||||
"./page": {
|
||||
"node": "./dist/page.js",
|
||||
"import": "./dist/page.js",
|
||||
"types": "./dist/page.d.ts"
|
||||
"import": "./dist/layouts/docs/page/index.js",
|
||||
"types": "./dist/layouts/docs/page/index.d.ts"
|
||||
},
|
||||
"./provider": {
|
||||
"import": "./dist/provider/index.js",
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { Image, type ImageProps } from 'fumadocs-core/framework';
|
||||
import { type ImgHTMLAttributes } from 'react';
|
||||
import './image-zoom.css';
|
||||
import type { ComponentProps } from 'react';
|
||||
import Zoom, { type UncontrolledProps } from 'react-medium-image-zoom';
|
||||
|
||||
export type ImageZoomProps = ImageProps & {
|
||||
/**
|
||||
* Image props when zoom in
|
||||
*/
|
||||
zoomInProps?: ImgHTMLAttributes<HTMLImageElement>;
|
||||
zoomInProps?: ComponentProps<'img'>;
|
||||
|
||||
/**
|
||||
* Props for `react-medium-image-zoom`
|
||||
|
||||
+7
-12
@@ -2,16 +2,11 @@
|
||||
import * as Primitive from 'fumadocs-core/toc';
|
||||
import { type ComponentProps, useEffect, useRef, useState } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { TocThumb } from '@/components/layout/toc-thumb';
|
||||
import { useTOCItems } from '@/components/layout/toc';
|
||||
import { useTOCItems, TocThumb } from '.';
|
||||
import { mergeRefs } from '@/utils/merge-refs';
|
||||
import { useI18n } from '@/contexts/i18n';
|
||||
|
||||
export default function ClerkTOCItems({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<'div'>) {
|
||||
export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const items = useTOCItems();
|
||||
const { text } = useI18n();
|
||||
@@ -77,7 +72,7 @@ export default function ClerkTOCItems({
|
||||
|
||||
return (
|
||||
<>
|
||||
{svg ? (
|
||||
{svg && (
|
||||
<div
|
||||
className="absolute start-0 top-0 rtl:-scale-x-100"
|
||||
style={{
|
||||
@@ -96,7 +91,7 @@ export default function ClerkTOCItems({
|
||||
className="mt-(--fd-top) h-(--fd-height) bg-fd-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
<div
|
||||
ref={mergeRefs(containerRef, ref)}
|
||||
className={cn('flex flex-col', className)}
|
||||
@@ -144,9 +139,9 @@ function TOCItem({
|
||||
style={{
|
||||
paddingInlineStart: getItemOffset(item.depth),
|
||||
}}
|
||||
className="prose relative py-1.5 text-sm text-fd-muted-foreground hover:text-fd-accent-foreground transition-colors [overflow-wrap:anywhere] first:pt-0 last:pb-0 data-[active=true]:text-fd-primary"
|
||||
className="prose relative py-1.5 text-sm text-fd-muted-foreground hover:text-fd-accent-foreground transition-colors wrap-anywhere first:pt-0 last:pb-0 data-[active=true]:text-fd-primary"
|
||||
>
|
||||
{offset !== upperOffset ? (
|
||||
{offset !== upperOffset && (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
@@ -161,7 +156,7 @@ function TOCItem({
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
) : null}
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-y-0 w-px bg-fd-foreground/10',
|
||||
+5
-48
@@ -1,53 +1,10 @@
|
||||
'use client';
|
||||
import * as Primitive from 'fumadocs-core/toc';
|
||||
import { type ComponentProps, createContext, useContext, useRef } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useI18n } from '@/contexts/i18n';
|
||||
import { TocThumb } from '@/components/layout/toc-thumb';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { mergeRefs } from '@/utils/merge-refs';
|
||||
|
||||
const TOCContext = createContext<Primitive.TOCItemType[]>([]);
|
||||
|
||||
export function useTOCItems(): Primitive.TOCItemType[] {
|
||||
return useContext(TOCContext);
|
||||
}
|
||||
|
||||
export function TOCProvider({
|
||||
toc,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof Primitive.AnchorProvider>) {
|
||||
return (
|
||||
<TOCContext value={toc}>
|
||||
<Primitive.AnchorProvider toc={toc} {...props}>
|
||||
{children}
|
||||
</Primitive.AnchorProvider>
|
||||
</TOCContext>
|
||||
);
|
||||
}
|
||||
|
||||
export function TOCScrollArea({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<'div'>) {
|
||||
const viewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={mergeRefs(viewRef, ref)}
|
||||
className={cn(
|
||||
'relative min-h-0 text-sm ms-px overflow-auto [scrollbar-width:none] [mask-image:linear-gradient(to_bottom,transparent,white_16px,white_calc(100%-16px),transparent)] py-3',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Primitive.ScrollProvider containerRef={viewRef}>
|
||||
{props.children}
|
||||
</Primitive.ScrollProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { type ComponentProps, useRef } from 'react';
|
||||
import { useTOCItems, TocThumb } from '.';
|
||||
import * as Primitive from 'fumadocs-core/toc';
|
||||
|
||||
export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -88,7 +45,7 @@ function TOCItem({ item }: { item: Primitive.TOCItemType }) {
|
||||
<Primitive.TOCItem
|
||||
href={item.url}
|
||||
className={cn(
|
||||
'prose py-1.5 text-sm text-fd-muted-foreground transition-colors [overflow-wrap:anywhere] first:pt-0 last:pb-0 data-[active=true]:text-fd-primary',
|
||||
'prose py-1.5 text-sm text-fd-muted-foreground transition-colors wrap-anywhere first:pt-0 last:pb-0 data-[active=true]:text-fd-primary',
|
||||
item.depth <= 2 && 'ps-3',
|
||||
item.depth === 3 && 'ps-6',
|
||||
item.depth >= 4 && 'ps-8',
|
||||
+52
-4
@@ -1,11 +1,59 @@
|
||||
'use client';
|
||||
import * as Primitive from 'fumadocs-core/toc';
|
||||
import {
|
||||
type HTMLAttributes,
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
use,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useActiveAnchors } from 'fumadocs-core/toc';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { mergeRefs } from '@/utils/merge-refs';
|
||||
|
||||
const TOCContext = createContext<Primitive.TOCItemType[]>([]);
|
||||
|
||||
export function useTOCItems(): Primitive.TOCItemType[] {
|
||||
return use(TOCContext);
|
||||
}
|
||||
|
||||
export function TOCProvider({
|
||||
toc,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof Primitive.AnchorProvider>) {
|
||||
return (
|
||||
<TOCContext value={toc}>
|
||||
<Primitive.AnchorProvider toc={toc} {...props}>
|
||||
{children}
|
||||
</Primitive.AnchorProvider>
|
||||
</TOCContext>
|
||||
);
|
||||
}
|
||||
|
||||
export function TOCScrollArea({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<'div'>) {
|
||||
const viewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={mergeRefs(viewRef, ref)}
|
||||
className={cn(
|
||||
'relative min-h-0 text-sm ms-px overflow-auto [scrollbar-width:none] mask-[linear-gradient(to_bottom,transparent,white_16px,white_calc(100%-16px),transparent)] py-3',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Primitive.ScrollProvider containerRef={viewRef}>
|
||||
{props.children}
|
||||
</Primitive.ScrollProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TocThumb = [top: number, height: number];
|
||||
|
||||
@@ -16,7 +64,7 @@ interface RefProps {
|
||||
export function TocThumb({
|
||||
containerRef,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & RefProps) {
|
||||
}: ComponentProps<'div'> & RefProps) {
|
||||
const thumbRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
@@ -31,7 +79,7 @@ function Updater({
|
||||
containerRef,
|
||||
thumbRef,
|
||||
}: RefProps & { thumbRef: RefObject<HTMLElement | null> }) {
|
||||
const active = useActiveAnchors();
|
||||
const active = Primitive.useActiveAnchors();
|
||||
const onPrint = useEffectEvent(() => {
|
||||
if (!containerRef.current || !thumbRef.current) return;
|
||||
|
||||
@@ -6,67 +6,32 @@ import { cn } from '@/utils/cn';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { useSidebar } from '@/contexts/sidebar';
|
||||
import { useNav } from '@/contexts/layout';
|
||||
import { SidebarCollapseTrigger } from '@/components/layout/sidebar';
|
||||
import { SearchToggle } from '@/components/layout/search-toggle';
|
||||
import type { Option } from '@/components/layout/root-toggle';
|
||||
import { SidebarCollapseTrigger } from './sidebar';
|
||||
import { SearchToggle } from '../shared/search-toggle';
|
||||
import type { Option } from '@/layouts/shared/root-toggle';
|
||||
import { usePathname } from 'fumadocs-core/framework';
|
||||
import { isTabActive } from '@/utils/is-active';
|
||||
import Link from 'fumadocs-core/link';
|
||||
|
||||
export function Navbar(props: ComponentProps<'header'>) {
|
||||
export function LayoutHeader(props: ComponentProps<'header'>) {
|
||||
const { isTransparent } = useNav();
|
||||
|
||||
return (
|
||||
<header
|
||||
id="nd-subnav"
|
||||
{...props}
|
||||
className={cn(
|
||||
'fixed top-(--fd-banner-height) left-0 right-(--removed-body-scroll-bar-size,0) z-30 flex items-center ps-4 pe-2.5 border-b transition-colors backdrop-blur-sm',
|
||||
!isTransparent && 'bg-fd-background/80',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<header data-transparent={isTransparent} {...props}>
|
||||
{props.children}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutBody(props: ComponentProps<'main'>) {
|
||||
const { collapsed } = useSidebar();
|
||||
|
||||
return (
|
||||
<main
|
||||
id="nd-docs-layout"
|
||||
{...props}
|
||||
className={cn(
|
||||
'flex flex-1 flex-col pt-(--fd-nav-height) transition-[padding] fd-default-layout',
|
||||
!collapsed && 'mx-(--fd-layout-offset)',
|
||||
props.className,
|
||||
)}
|
||||
style={{
|
||||
...props.style,
|
||||
paddingInlineStart: collapsed
|
||||
? 'min(calc(100vw - var(--fd-page-width)), var(--fd-sidebar-width))'
|
||||
: 'var(--fd-sidebar-width)',
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollapsibleControl() {
|
||||
const { collapsed } = useSidebar();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed flex shadow-lg transition-opacity rounded-xl p-0.5 border bg-fd-muted text-fd-muted-foreground z-10 max-md:hidden xl:start-4 max-xl:end-4',
|
||||
'fixed flex top-4 shadow-lg transition-opacity rounded-xl p-0.5 border bg-fd-muted text-fd-muted-foreground z-10 max-md:hidden xl:start-4 max-xl:end-4',
|
||||
!collapsed && 'pointer-events-none opacity-0',
|
||||
)}
|
||||
style={{
|
||||
top: 'calc(var(--fd-banner-height) + var(--fd-tocnav-height) + var(--spacing) * 4)',
|
||||
}}
|
||||
>
|
||||
<SidebarCollapseTrigger
|
||||
className={cn(
|
||||
@@ -99,7 +64,7 @@ export function LayoutTabs({
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'flex flex-row items-end gap-6 overflow-auto',
|
||||
'flex flex-row items-end gap-6 overflow-auto [grid-area:main]',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -14,43 +14,30 @@ import {
|
||||
type SidebarComponents,
|
||||
SidebarContent,
|
||||
SidebarContentMobile,
|
||||
SidebarFolder,
|
||||
SidebarFolderContent,
|
||||
SidebarFolderLink,
|
||||
SidebarFolderTrigger,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarItem,
|
||||
SidebarLinkItem,
|
||||
SidebarPageTree,
|
||||
type SidebarProps,
|
||||
SidebarTrigger,
|
||||
SidebarViewport,
|
||||
} from '@/components/layout/sidebar';
|
||||
import { type Option, RootToggle } from '@/components/layout/root-toggle';
|
||||
import {
|
||||
type BaseLayoutProps,
|
||||
BaseLinkItem,
|
||||
getLinks,
|
||||
type LinkItemType,
|
||||
} from '@/layouts/shared';
|
||||
} from './sidebar';
|
||||
import { type Option, RootToggle } from '@/layouts/shared/root-toggle';
|
||||
import { type BaseLayoutProps, resolveLinkItems } from '@/layouts/shared';
|
||||
import { LinkItem } from '@/layouts/shared/link-item';
|
||||
import {
|
||||
LanguageToggle,
|
||||
LanguageToggleText,
|
||||
} from '@/components/layout/language-toggle';
|
||||
import {
|
||||
CollapsibleControl,
|
||||
LayoutBody,
|
||||
LayoutTabs,
|
||||
Navbar,
|
||||
} from '@/layouts/docs/client';
|
||||
} from '@/layouts/shared/language-toggle';
|
||||
import { CollapsibleControl, LayoutTabs, LayoutHeader } from './client';
|
||||
import { TreeContextProvider } from '@/contexts/tree';
|
||||
import { ThemeToggle } from '@/components/layout/theme-toggle';
|
||||
import { ThemeToggle } from '../shared/theme-toggle';
|
||||
import { NavProvider } from '@/contexts/layout';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import {
|
||||
LargeSearchToggle,
|
||||
SearchToggle,
|
||||
} from '@/components/layout/search-toggle';
|
||||
} from '@/layouts/shared/search-toggle';
|
||||
import {
|
||||
getSidebarTabs,
|
||||
type GetSidebarTabsOptions,
|
||||
@@ -119,10 +106,7 @@ export function DocsLayout({
|
||||
}
|
||||
return [];
|
||||
}, [tree, sidebarTabs]);
|
||||
const links = getLinks(props.links ?? [], props.githubUrl);
|
||||
const sidebarVariables = cn(
|
||||
'md:[--fd-sidebar-width:268px] lg:[--fd-sidebar-width:286px]',
|
||||
);
|
||||
const links = resolveLinkItems(props);
|
||||
|
||||
function sidebar() {
|
||||
const {
|
||||
@@ -160,7 +144,7 @@ export function DocsLayout({
|
||||
<div className="flex text-fd-muted-foreground items-center gap-1.5">
|
||||
<div className="flex flex-1">
|
||||
{iconLinks.map((item, i) => (
|
||||
<BaseLinkItem
|
||||
<LinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(
|
||||
@@ -173,15 +157,15 @@ export function DocsLayout({
|
||||
aria-label={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
</BaseLinkItem>
|
||||
</LinkItem>
|
||||
))}
|
||||
</div>
|
||||
{i18n ? (
|
||||
{i18n && (
|
||||
<LanguageToggle>
|
||||
<Languages className="size-4.5" />
|
||||
<LanguageToggleText />
|
||||
</LanguageToggle>
|
||||
) : null}
|
||||
)}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? (
|
||||
<ThemeToggle className="p-0" mode={themeSwitch.mode} />
|
||||
@@ -207,7 +191,13 @@ export function DocsLayout({
|
||||
);
|
||||
|
||||
const content = (
|
||||
<SidebarContent {...rest}>
|
||||
<SidebarContent
|
||||
{...rest}
|
||||
className={cn(
|
||||
'sticky top-(--fd-docs-sidebar-top) [grid-area:sidebar] h-[calc(100dvh-var(--fd-docs-sidebar-top))] flex flex-col items-end z-20 bg-fd-card text-sm border-e min-h-0 transition-[top,opacity,translate,width] duration-200 max-md:hidden *:w-(--fd-sidebar-width)',
|
||||
rest.className,
|
||||
)}
|
||||
>
|
||||
<SidebarHeader>
|
||||
<div className="flex">
|
||||
<Link
|
||||
@@ -253,7 +243,7 @@ export function DocsLayout({
|
||||
</LanguageToggle>
|
||||
)}
|
||||
{iconLinks.map((item, i) => (
|
||||
<BaseLinkItem
|
||||
<LinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(
|
||||
@@ -262,7 +252,7 @@ export function DocsLayout({
|
||||
aria-label={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
</BaseLinkItem>
|
||||
</LinkItem>
|
||||
))}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? (
|
||||
@@ -296,98 +286,58 @@ export function DocsLayout({
|
||||
return (
|
||||
<TreeContextProvider tree={tree}>
|
||||
<NavProvider transparentMode={transparentMode}>
|
||||
{nav.enabled !== false &&
|
||||
(nav.component ?? (
|
||||
<Navbar className="h-(--fd-nav-height) on-root:[--fd-nav-height:56px] md:on-root:[--fd-nav-height:0px] md:hidden">
|
||||
<Link
|
||||
href={nav.url ?? '/'}
|
||||
className="inline-flex items-center gap-2.5 font-semibold"
|
||||
>
|
||||
{nav.title}
|
||||
</Link>
|
||||
<div className="flex-1">{nav.children}</div>
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.sm ?? (
|
||||
<SearchToggle className="p-2" hideIfDisabled />
|
||||
))}
|
||||
{sidebarEnabled && (
|
||||
<SidebarTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
className: 'p-2',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarTrigger>
|
||||
)}
|
||||
</Navbar>
|
||||
))}
|
||||
<LayoutBody
|
||||
<div
|
||||
id="nd-docs-layout"
|
||||
{...props.containerProps}
|
||||
className={cn(
|
||||
'md:[&_#nd-page_article]:pt-12 xl:[&_#nd-page_article]:px-8',
|
||||
sidebarEnabled && sidebarVariables,
|
||||
props.containerProps?.className,
|
||||
)}
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplate: `"sidebar header header"
|
||||
"sidebar toc-popover toc-popover"
|
||||
"sidebar main toc" 1fr / minmax(var(--fd-sidebar-width), 1fr) minmax(0px, 900px) minmax(var(--fd-toc-width), 1fr)`,
|
||||
}}
|
||||
>
|
||||
{nav.enabled !== false &&
|
||||
(nav.component ?? (
|
||||
<LayoutHeader
|
||||
id="nd-subnav"
|
||||
className="[grid-area:header] sticky top-(--fd-docs-nav-top) z-30 flex items-center ps-4 pe-2.5 border-b transition-colors backdrop-blur-sm h-14 md:hidden data-[transparent=false]:bg-fd-background/80"
|
||||
>
|
||||
<Link
|
||||
href={nav.url ?? '/'}
|
||||
className="inline-flex items-center gap-2.5 font-semibold"
|
||||
>
|
||||
{nav.title}
|
||||
</Link>
|
||||
<div className="flex-1">{nav.children}</div>
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.sm ?? (
|
||||
<SearchToggle className="p-2" hideIfDisabled />
|
||||
))}
|
||||
{sidebarEnabled && (
|
||||
<SidebarTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
className: 'p-2',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarTrigger>
|
||||
)}
|
||||
</LayoutHeader>
|
||||
))}
|
||||
{sidebarEnabled && sidebar()}
|
||||
{tabMode === 'top' && tabs.length > 0 && (
|
||||
<LayoutTabs
|
||||
options={tabs}
|
||||
className="sticky top-[calc(var(--fd-nav-height)+var(--fd-tocnav-height))] z-10 bg-fd-background border-b px-6 pt-3 xl:px-8 max-md:hidden"
|
||||
className="z-10 bg-fd-background border-b px-6 pt-3 xl:px-8 max-md:hidden"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</LayoutBody>
|
||||
</div>
|
||||
</NavProvider>
|
||||
</TreeContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLinkItem({
|
||||
item,
|
||||
...props
|
||||
}: {
|
||||
item: Exclude<LinkItemType, { type: 'icon' }>;
|
||||
className?: string;
|
||||
}) {
|
||||
if (item.type === 'menu')
|
||||
return (
|
||||
<SidebarFolder {...props}>
|
||||
{item.url ? (
|
||||
<SidebarFolderLink href={item.url} external={item.external}>
|
||||
{item.icon}
|
||||
{item.text}
|
||||
</SidebarFolderLink>
|
||||
) : (
|
||||
<SidebarFolderTrigger>
|
||||
{item.icon}
|
||||
{item.text}
|
||||
</SidebarFolderTrigger>
|
||||
)}
|
||||
<SidebarFolderContent>
|
||||
{item.items.map((child, i) => (
|
||||
<SidebarLinkItem key={i} item={child} />
|
||||
))}
|
||||
</SidebarFolderContent>
|
||||
</SidebarFolder>
|
||||
);
|
||||
|
||||
if (item.type === 'custom') return <div {...props}>{item.children}</div>;
|
||||
|
||||
return (
|
||||
<SidebarItem
|
||||
href={item.url}
|
||||
icon={item.icon}
|
||||
external={item.external}
|
||||
{...props}
|
||||
>
|
||||
{item.text}
|
||||
</SidebarItem>
|
||||
);
|
||||
}
|
||||
|
||||
export { CollapsibleControl, Navbar, SidebarTrigger, type LinkItemType };
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { type ComponentProps } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import {
|
||||
type BreadcrumbProps,
|
||||
type FooterProps,
|
||||
PageBreadcrumb,
|
||||
PageFooter,
|
||||
PageLastUpdate,
|
||||
PageTOC,
|
||||
PageTOCPopover,
|
||||
PageTOCPopoverContent,
|
||||
PageTOCPopoverTrigger,
|
||||
} from './page-client';
|
||||
import { TOCItems, TOCProvider, TOCScrollArea } from '@/components/layout/toc';
|
||||
import { Text } from 'lucide-react';
|
||||
import { I18nLabel } from '@/contexts/i18n';
|
||||
import ClerkTOCItems from '@/components/layout/toc-clerk';
|
||||
import type { AnchorProviderProps } from 'fumadocs-core/toc';
|
||||
|
||||
export function PageTOCTitle(props: ComponentProps<'h2'>) {
|
||||
return (
|
||||
<h3
|
||||
id="toc-title"
|
||||
{...props}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 text-sm text-fd-muted-foreground',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<Text className="size-4" />
|
||||
<I18nLabel label="toc" />
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTOCItems({
|
||||
variant = 'normal',
|
||||
...props
|
||||
}: ComponentProps<'div'> & { variant?: 'clerk' | 'normal' }) {
|
||||
return (
|
||||
<TOCScrollArea {...props}>
|
||||
{variant === 'clerk' ? <ClerkTOCItems /> : <TOCItems />}
|
||||
</TOCScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTOCPopoverItems({
|
||||
variant = 'normal',
|
||||
...props
|
||||
}: ComponentProps<'div'> & { variant?: 'clerk' | 'normal' }) {
|
||||
return (
|
||||
<TOCScrollArea {...props}>
|
||||
{variant === 'clerk' ? <ClerkTOCItems /> : <TOCItems />}
|
||||
</TOCScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageArticle(props: ComponentProps<'article'>) {
|
||||
return (
|
||||
<article
|
||||
{...props}
|
||||
className={cn(
|
||||
'flex min-w-0 w-full flex-col gap-4 pt-8 px-4 md:px-6 md:mx-auto',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RootProps extends ComponentProps<'div'> {
|
||||
toc?: Omit<AnchorProviderProps, 'children'> | false;
|
||||
}
|
||||
|
||||
export function PageRoot({ toc = false, children, ...props }: RootProps) {
|
||||
const content = (
|
||||
<div
|
||||
id="nd-page"
|
||||
{...props}
|
||||
className={cn(
|
||||
'flex flex-1 w-full mx-auto max-w-(--fd-page-width) pt-(--fd-tocnav-height) pe-(--fd-toc-width)',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (toc) return <TOCProvider {...toc}>{content}</TOCProvider>;
|
||||
return content;
|
||||
}
|
||||
|
||||
export {
|
||||
PageBreadcrumb,
|
||||
PageFooter,
|
||||
PageLastUpdate,
|
||||
PageTOC,
|
||||
PageTOCPopover,
|
||||
PageTOCPopoverTrigger,
|
||||
PageTOCPopoverContent,
|
||||
type FooterProps,
|
||||
type BreadcrumbProps,
|
||||
};
|
||||
@@ -0,0 +1,388 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type ComponentProps,
|
||||
Fragment,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
createContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
use,
|
||||
} from 'react';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from '@/icons';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useI18n } from '@/contexts/i18n';
|
||||
import { useTreeContext, useTreePath } from '@/contexts/tree';
|
||||
import type * as PageTree from 'fumadocs-core/page-tree';
|
||||
import { usePathname } from 'fumadocs-core/framework';
|
||||
import {
|
||||
type BreadcrumbOptions,
|
||||
getBreadcrumbItemsFromPath,
|
||||
} from 'fumadocs-core/breadcrumb';
|
||||
import { useNav } from '@/contexts/layout';
|
||||
import { isActive } from '@/utils/is-active';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { useTOCItems } from '@/components/toc';
|
||||
import { useActiveAnchor } from 'fumadocs-core/toc';
|
||||
|
||||
const TocPopoverContext = createContext<{
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
} | null>(null);
|
||||
|
||||
export function PageTOCPopover(props: ComponentProps<'div'>) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { isTransparent } = useNav();
|
||||
|
||||
const onClick = useEffectEvent((e: Event) => {
|
||||
if (!open) return;
|
||||
|
||||
if (ref.current && !ref.current.contains(e.target as HTMLElement))
|
||||
setOpen(false);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('click', onClick);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('click', onClick);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TocPopoverContext
|
||||
value={useMemo(
|
||||
() => ({
|
||||
open,
|
||||
setOpen,
|
||||
}),
|
||||
[setOpen, open],
|
||||
)}
|
||||
>
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="sticky top-(--fd-docs-toc-popover-top) z-10 [grid-area:toc-popover] h-10 xl:hidden"
|
||||
>
|
||||
<header
|
||||
ref={ref}
|
||||
id="nd-tocnav"
|
||||
{...props}
|
||||
className={cn(
|
||||
'border-b backdrop-blur-sm transition-colors',
|
||||
(!isTransparent || open) && 'bg-fd-background/80',
|
||||
open && 'shadow-lg',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</header>
|
||||
</Collapsible>
|
||||
</TocPopoverContext>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTOCPopoverTrigger(props: ComponentProps<'button'>) {
|
||||
const { text } = useI18n();
|
||||
const { open } = use(TocPopoverContext)!;
|
||||
const items = useTOCItems();
|
||||
const active = useActiveAnchor();
|
||||
const selected = useMemo(
|
||||
() => items.findIndex((item) => active === item.url.slice(1)),
|
||||
[items, active],
|
||||
);
|
||||
const path = useTreePath().at(-1);
|
||||
const showItem = selected !== -1 && !open;
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
{...props}
|
||||
className={cn(
|
||||
'flex w-full h-10 items-center text-sm text-fd-muted-foreground gap-2.5 px-4 py-2.5 text-start focus-visible:outline-none [&_svg]:size-4 md:px-6',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<ProgressCircle
|
||||
value={(selected + 1) / Math.max(1, items.length)}
|
||||
max={1}
|
||||
className={cn('shrink-0', open && 'text-fd-primary')}
|
||||
/>
|
||||
<span className="grid flex-1 *:my-auto *:row-start-1 *:col-start-1">
|
||||
<span
|
||||
className={cn(
|
||||
'truncate transition-all',
|
||||
open && 'text-fd-foreground',
|
||||
showItem && 'opacity-0 -translate-y-full pointer-events-none',
|
||||
)}
|
||||
>
|
||||
{path?.name ?? text.toc}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate transition-all',
|
||||
!showItem && 'opacity-0 translate-y-full pointer-events-none',
|
||||
)}
|
||||
>
|
||||
{items[selected]?.title}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'shrink-0 transition-transform mx-0.5',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProgressCircleProps
|
||||
extends Omit<React.ComponentProps<'svg'>, 'strokeWidth'> {
|
||||
value: number;
|
||||
strokeWidth?: number;
|
||||
size?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
function clamp(input: number, min: number, max: number): number {
|
||||
if (input < min) return min;
|
||||
if (input > max) return max;
|
||||
return input;
|
||||
}
|
||||
|
||||
function ProgressCircle({
|
||||
value,
|
||||
strokeWidth = 2,
|
||||
size = 24,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...restSvgProps
|
||||
}: ProgressCircleProps) {
|
||||
const normalizedValue = clamp(value, min, max);
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const progress = (normalizedValue / max) * circumference;
|
||||
const circleProps = {
|
||||
cx: size / 2,
|
||||
cy: size / 2,
|
||||
r: radius,
|
||||
fill: 'none',
|
||||
strokeWidth,
|
||||
};
|
||||
|
||||
return (
|
||||
<svg
|
||||
role="progressbar"
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
aria-valuenow={normalizedValue}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
{...restSvgProps}
|
||||
>
|
||||
<circle {...circleProps} className="stroke-current/25" />
|
||||
<circle
|
||||
{...circleProps}
|
||||
stroke="currentColor"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference - progress}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
className="transition-all"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTOCPopoverContent(props: ComponentProps<'div'>) {
|
||||
return (
|
||||
<CollapsibleContent
|
||||
data-toc-popover=""
|
||||
{...props}
|
||||
className={cn('flex flex-col px-4 max-h-[50vh] md:px-6', props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</CollapsibleContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageLastUpdate({
|
||||
date: value,
|
||||
...props
|
||||
}: Omit<ComponentProps<'p'>, 'children'> & { date: Date | string }) {
|
||||
const { text } = useI18n();
|
||||
const [date, setDate] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// to the timezone of client
|
||||
setDate(new Date(value).toLocaleDateString());
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<p
|
||||
{...props}
|
||||
className={cn('text-sm text-fd-muted-foreground', props.className)}
|
||||
>
|
||||
{text.lastUpdate} {date}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
type Item = Pick<PageTree.Item, 'name' | 'description' | 'url'>;
|
||||
export interface FooterProps extends ComponentProps<'div'> {
|
||||
/**
|
||||
* Items including information for the next and previous page
|
||||
*/
|
||||
items?: {
|
||||
previous?: Item;
|
||||
next?: Item;
|
||||
};
|
||||
}
|
||||
|
||||
function scanNavigationList(tree: PageTree.Node[]) {
|
||||
const list: PageTree.Item[] = [];
|
||||
|
||||
tree.forEach((node) => {
|
||||
if (node.type === 'folder') {
|
||||
if (node.index) {
|
||||
list.push(node.index);
|
||||
}
|
||||
|
||||
list.push(...scanNavigationList(node.children));
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === 'page' && !node.external) {
|
||||
list.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
const listCache = new Map<string, PageTree.Item[]>();
|
||||
|
||||
export function PageFooter({ items, ...props }: FooterProps) {
|
||||
const { root } = useTreeContext();
|
||||
const pathname = usePathname();
|
||||
|
||||
const { previous, next } = useMemo(() => {
|
||||
if (items) return items;
|
||||
|
||||
const cached = listCache.get(root.$id);
|
||||
const list = cached ?? scanNavigationList(root.children);
|
||||
listCache.set(root.$id, list);
|
||||
|
||||
const idx = list.findIndex((item) => isActive(item.url, pathname, false));
|
||||
|
||||
if (idx === -1) return {};
|
||||
return {
|
||||
previous: list[idx - 1],
|
||||
next: list[idx + 1],
|
||||
};
|
||||
}, [items, pathname, root]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'@container grid gap-4',
|
||||
previous && next ? 'grid-cols-2' : 'grid-cols-1',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{previous ? <FooterItem item={previous} index={0} /> : null}
|
||||
{next ? <FooterItem item={next} index={1} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterItem({ item, index }: { item: Item; index: 0 | 1 }) {
|
||||
const { text } = useI18n();
|
||||
const Icon = index === 0 ? ChevronLeft : ChevronRight;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={item.url}
|
||||
className={cn(
|
||||
'flex flex-col gap-2 rounded-lg border p-4 text-sm transition-colors hover:bg-fd-accent/80 hover:text-fd-accent-foreground @max-lg:col-span-full',
|
||||
index === 1 && 'text-end',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 font-medium',
|
||||
index === 1 && 'flex-row-reverse',
|
||||
)}
|
||||
>
|
||||
<Icon className="-mx-1 size-4 shrink-0 rtl:rotate-180" />
|
||||
<p>{item.name}</p>
|
||||
</div>
|
||||
<p className="text-fd-muted-foreground truncate">
|
||||
{item.description ?? (index === 0 ? text.previousPage : text.nextPage)}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export type BreadcrumbProps = BreadcrumbOptions & ComponentProps<'div'>;
|
||||
|
||||
export function PageBreadcrumb({
|
||||
includeRoot,
|
||||
includeSeparator,
|
||||
includePage,
|
||||
...props
|
||||
}: BreadcrumbProps) {
|
||||
const path = useTreePath();
|
||||
const { root } = useTreeContext();
|
||||
const items = useMemo(() => {
|
||||
return getBreadcrumbItemsFromPath(root, path, {
|
||||
includePage,
|
||||
includeSeparator,
|
||||
includeRoot,
|
||||
});
|
||||
}, [includePage, includeRoot, includeSeparator, path, root]);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-sm text-fd-muted-foreground',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
const className = cn(
|
||||
'truncate',
|
||||
i === items.length - 1 && 'text-fd-primary font-medium',
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
{i !== 0 && <ChevronRight className="size-3.5 shrink-0" />}
|
||||
{item.url ? (
|
||||
<Link
|
||||
href={item.url}
|
||||
className={cn(className, 'transition-opacity hover:opacity-80')}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={className}>{item.name}</span>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { Edit, Text } from 'lucide-react';
|
||||
import { I18nLabel } from '@/contexts/i18n';
|
||||
import {
|
||||
type BreadcrumbProps,
|
||||
type FooterProps,
|
||||
PageBreadcrumb,
|
||||
PageFooter,
|
||||
PageLastUpdate,
|
||||
PageTOCPopover,
|
||||
PageTOCPopoverContent,
|
||||
PageTOCPopoverTrigger,
|
||||
} from './client';
|
||||
import type { AnchorProviderProps, TOCItemType } from 'fumadocs-core/toc';
|
||||
import * as TocDefault from '@/components/toc/default';
|
||||
import * as TocClerk from '@/components/toc/clerk';
|
||||
import { TOCProvider, TOCScrollArea } from '@/components/toc';
|
||||
|
||||
interface BreadcrumbOptions extends BreadcrumbProps {
|
||||
enabled: boolean;
|
||||
component: ReactNode;
|
||||
}
|
||||
|
||||
interface FooterOptions extends FooterProps {
|
||||
enabled: boolean;
|
||||
component: ReactNode;
|
||||
}
|
||||
|
||||
interface EditOnGitHubOptions
|
||||
extends Omit<ComponentProps<'a'>, 'href' | 'children'> {
|
||||
owner: string;
|
||||
repo: string;
|
||||
|
||||
/**
|
||||
* SHA or ref (branch or tag) name.
|
||||
*
|
||||
* @defaultValue main
|
||||
*/
|
||||
sha?: string;
|
||||
|
||||
/**
|
||||
* File path in the repo
|
||||
*/
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface DocsPageProps {
|
||||
toc?: TOCItemType[];
|
||||
tableOfContent?: Partial<TableOfContentOptions>;
|
||||
tableOfContentPopover?: Partial<TableOfContentPopoverOptions>;
|
||||
|
||||
/**
|
||||
* Extend the page to fill all available space
|
||||
*
|
||||
* @defaultValue false
|
||||
*/
|
||||
full?: boolean;
|
||||
|
||||
editOnGithub?: EditOnGitHubOptions;
|
||||
lastUpdate?: Date | string | number;
|
||||
|
||||
/**
|
||||
* Replace or disable breadcrumb
|
||||
*/
|
||||
breadcrumb?: Partial<BreadcrumbOptions>;
|
||||
|
||||
/**
|
||||
* Footer navigation, you can disable it by passing `false`
|
||||
*/
|
||||
footer?: Partial<FooterOptions>;
|
||||
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
type TableOfContentOptions = Pick<AnchorProviderProps, 'single'> & {
|
||||
/**
|
||||
* Custom content in TOC container, before the main TOC
|
||||
*/
|
||||
header?: ReactNode;
|
||||
|
||||
/**
|
||||
* Custom content in TOC container, after the main TOC
|
||||
*/
|
||||
footer?: ReactNode;
|
||||
|
||||
enabled: boolean;
|
||||
component: ReactNode;
|
||||
|
||||
/**
|
||||
* @defaultValue 'normal'
|
||||
*/
|
||||
style?: 'normal' | 'clerk';
|
||||
};
|
||||
|
||||
type TableOfContentPopoverOptions = Omit<TableOfContentOptions, 'single'>;
|
||||
|
||||
export function DocsPage({
|
||||
breadcrumb: {
|
||||
enabled: breadcrumbEnabled = true,
|
||||
component: breadcrumb,
|
||||
...breadcrumbProps
|
||||
} = {},
|
||||
footer = {},
|
||||
full = false,
|
||||
tableOfContentPopover: {
|
||||
enabled: tocPopoverEnabled,
|
||||
component: tocPopover,
|
||||
...tocPopoverOptions
|
||||
} = {},
|
||||
tableOfContent: {
|
||||
enabled: tocEnabled,
|
||||
component: tocReplace,
|
||||
...tocOptions
|
||||
} = {},
|
||||
toc = [],
|
||||
editOnGithub,
|
||||
lastUpdate,
|
||||
children,
|
||||
}: DocsPageProps) {
|
||||
// disable TOC on full mode, you can still enable it with `enabled` option.
|
||||
tocEnabled ??=
|
||||
!full &&
|
||||
(toc.length > 0 ||
|
||||
tocOptions.footer !== undefined ||
|
||||
tocOptions.header !== undefined);
|
||||
|
||||
tocPopoverEnabled ??=
|
||||
toc.length > 0 ||
|
||||
tocPopoverOptions.header !== undefined ||
|
||||
tocPopoverOptions.footer !== undefined;
|
||||
|
||||
let wrapper = (children: ReactNode) => children;
|
||||
|
||||
if (tocEnabled || tocPopoverEnabled) {
|
||||
wrapper = (children) => (
|
||||
<TOCProvider single={tocOptions.single} toc={toc}>
|
||||
{children}
|
||||
</TOCProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return wrapper(
|
||||
<>
|
||||
{tocPopoverEnabled &&
|
||||
(tocPopover ?? (
|
||||
<PageTOCPopover>
|
||||
<PageTOCPopoverTrigger />
|
||||
<PageTOCPopoverContent>
|
||||
{tocPopoverOptions.header}
|
||||
<TOCScrollArea>
|
||||
{tocPopoverOptions.style === 'clerk' ? (
|
||||
<TocClerk.TOCItems />
|
||||
) : (
|
||||
<TocDefault.TOCItems />
|
||||
)}
|
||||
</TOCScrollArea>
|
||||
{tocPopoverOptions.footer}
|
||||
</PageTOCPopoverContent>
|
||||
</PageTOCPopover>
|
||||
))}
|
||||
<article
|
||||
id="nd-page"
|
||||
className="flex flex-col min-w-0 [grid-area:main] px-4 py-6 gap-4 md:px-6 md:pt-8 xl:px-8 xl:pt-14"
|
||||
>
|
||||
{breadcrumbEnabled &&
|
||||
(breadcrumb ?? <PageBreadcrumb {...breadcrumbProps} />)}
|
||||
{children}
|
||||
<div className="flex flex-row flex-wrap items-center justify-between gap-4 empty:hidden">
|
||||
{editOnGithub && (
|
||||
<EditOnGitHub
|
||||
href={`https://github.com/${editOnGithub.owner}/${editOnGithub.repo}/blob/${editOnGithub.sha}/${editOnGithub.path.startsWith('/') ? editOnGithub.path.slice(1) : editOnGithub.path}`}
|
||||
/>
|
||||
)}
|
||||
{lastUpdate && <PageLastUpdate date={new Date(lastUpdate)} />}
|
||||
</div>
|
||||
{footer.enabled !== false &&
|
||||
(footer.component ?? <PageFooter items={footer.items} />)}
|
||||
</article>
|
||||
{tocEnabled &&
|
||||
(tocReplace ?? (
|
||||
<div
|
||||
id="nd-toc"
|
||||
className="sticky top-(--fd-docs-toc-top) h-[calc(100dvh-var(--fd-docs-toc-top))] flex flex-col min-h-0 [grid-area:toc] w-(--fd-toc-width) pt-12 pe-4 pb-2 max-xl:hidden"
|
||||
>
|
||||
{tocOptions.header}
|
||||
<h3
|
||||
id="toc-title"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-fd-muted-foreground"
|
||||
>
|
||||
<Text className="size-4" />
|
||||
<I18nLabel label="toc" />
|
||||
</h3>
|
||||
<TOCScrollArea>
|
||||
{tocOptions.style === 'clerk' ? (
|
||||
<TocClerk.TOCItems />
|
||||
) : (
|
||||
<TocDefault.TOCItems />
|
||||
)}
|
||||
</TOCScrollArea>
|
||||
{tocOptions.footer}
|
||||
</div>
|
||||
))}
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
export function EditOnGitHub(props: ComponentProps<'a'>) {
|
||||
return (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
{...props}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
size: 'sm',
|
||||
className: 'gap-1.5 not-prose',
|
||||
}),
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children ?? (
|
||||
<>
|
||||
<Edit className="size-3.5" />
|
||||
<I18nLabel label="editOnGithub" />
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add typography styles
|
||||
*/
|
||||
export function DocsBody({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div {...props} className={cn('prose flex-1', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsDescription({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<'p'>) {
|
||||
// Don't render if no description provided
|
||||
if (children === undefined) return null;
|
||||
|
||||
return (
|
||||
<p
|
||||
{...props}
|
||||
className={cn('mb-8 text-lg text-fd-muted-foreground', className)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsTitle({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<'h1'>) {
|
||||
return (
|
||||
<h1 {...props} className={cn('text-[1.75em] font-semibold', className)}>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* For separate MDX page
|
||||
*/
|
||||
export function withArticle(props: ComponentProps<'main'>) {
|
||||
return (
|
||||
<main {...props} className={cn('container py-12', props.className)}>
|
||||
<article className="prose">{props.children}</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
'use client';
|
||||
import { ChevronDown, ExternalLink } from 'lucide-react';
|
||||
import { usePathname } from 'fumadocs-core/framework';
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type FC,
|
||||
Fragment,
|
||||
type ReactNode,
|
||||
use,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import Link, { type LinkProps } from 'fumadocs-core/link';
|
||||
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ScrollArea, ScrollViewport } from '@/components/ui/scroll-area';
|
||||
import { isActive } from '@/utils/is-active';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { type ScrollAreaProps } from '@radix-ui/react-scroll-area';
|
||||
import { useSidebar } from '@/contexts/sidebar';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import type {
|
||||
CollapsibleContentProps,
|
||||
CollapsibleTriggerProps,
|
||||
} from '@radix-ui/react-collapsible';
|
||||
import type * as PageTree from 'fumadocs-core/page-tree';
|
||||
import { useTreeContext, useTreePath } from '@/contexts/tree';
|
||||
import { useMediaQuery } from 'fumadocs-core/utils/use-media-query';
|
||||
import { Presence } from '@radix-ui/react-presence';
|
||||
import type { LinkItemType } from '@/layouts/shared';
|
||||
|
||||
export interface SidebarProps {
|
||||
/**
|
||||
* Open folders by default if their level is lower or equal to a specific level
|
||||
* (Starting from 1)
|
||||
*
|
||||
* @defaultValue 0
|
||||
*/
|
||||
defaultOpenLevel?: number;
|
||||
|
||||
/**
|
||||
* Prefetch links
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
prefetch?: boolean;
|
||||
|
||||
/**
|
||||
* Children to render
|
||||
*/
|
||||
Content: ReactNode;
|
||||
|
||||
/**
|
||||
* Alternative children for mobile
|
||||
*/
|
||||
Mobile?: ReactNode;
|
||||
}
|
||||
|
||||
interface InternalContext {
|
||||
defaultOpenLevel: number;
|
||||
prefetch: boolean;
|
||||
level: number;
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
'relative flex flex-row items-center gap-2 rounded-lg p-2 ps-(--sidebar-item-offset) text-start text-fd-muted-foreground [overflow-wrap:anywhere] [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
active: {
|
||||
true: 'bg-fd-primary/10 text-fd-primary',
|
||||
false:
|
||||
'transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Context = createContext<InternalContext | null>(null);
|
||||
const FolderContext = createContext<{
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
} | null>(null);
|
||||
|
||||
export function Sidebar({
|
||||
defaultOpenLevel = 0,
|
||||
prefetch = true,
|
||||
Mobile,
|
||||
Content,
|
||||
}: SidebarProps) {
|
||||
const isMobile = useMediaQuery('(width < 768px)') ?? false;
|
||||
const context = useMemo<InternalContext>(() => {
|
||||
return {
|
||||
defaultOpenLevel,
|
||||
prefetch,
|
||||
level: 1,
|
||||
};
|
||||
}, [defaultOpenLevel, prefetch]);
|
||||
|
||||
return (
|
||||
<Context.Provider value={context}>
|
||||
{isMobile && Mobile != null ? Mobile : Content}
|
||||
</Context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarContent(props: ComponentProps<'aside'>) {
|
||||
const { collapsed } = useSidebar();
|
||||
const [hover, setHover] = useState(false);
|
||||
const timerRef = useRef(0);
|
||||
const closeTimeRef = useRef(0);
|
||||
|
||||
useOnChange(collapsed, () => {
|
||||
setHover(false);
|
||||
closeTimeRef.current = Date.now() + 150;
|
||||
});
|
||||
|
||||
return (
|
||||
<aside
|
||||
id="nd-sidebar"
|
||||
{...props}
|
||||
data-collapsed={collapsed}
|
||||
className={cn(
|
||||
props.className,
|
||||
collapsed && [
|
||||
'rounded-xl border translate-x-(--fd-sidebar-offset) rtl:-translate-x-(--fd-sidebar-offset)',
|
||||
hover ? 'z-50 shadow-lg' : 'opacity-0',
|
||||
],
|
||||
)}
|
||||
onPointerEnter={(e) => {
|
||||
if (
|
||||
!collapsed ||
|
||||
e.pointerType === 'touch' ||
|
||||
closeTimeRef.current > Date.now()
|
||||
)
|
||||
return;
|
||||
window.clearTimeout(timerRef.current);
|
||||
setHover(true);
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
if (!collapsed || e.pointerType === 'touch') return;
|
||||
window.clearTimeout(timerRef.current);
|
||||
|
||||
timerRef.current = window.setTimeout(
|
||||
() => {
|
||||
setHover(false);
|
||||
closeTimeRef.current = Date.now() + 150;
|
||||
},
|
||||
Math.min(e.clientX, document.body.clientWidth - e.clientX) > 100
|
||||
? 0
|
||||
: 500,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarContentMobile({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<'aside'>) {
|
||||
const { open, setOpen } = useSidebar();
|
||||
const state = open ? 'open' : 'closed';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Presence present={open}>
|
||||
<div
|
||||
data-state={state}
|
||||
className="fixed z-40 inset-0 backdrop-blur-xs data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
</Presence>
|
||||
<Presence present={open}>
|
||||
{({ present }) => (
|
||||
<aside
|
||||
id="nd-sidebar-mobile"
|
||||
{...props}
|
||||
data-state={state}
|
||||
className={cn(
|
||||
'fixed text-[0.9375rem] flex flex-col shadow-lg border-s end-0 inset-y-0 w-[85%] max-w-[380px] z-40 bg-fd-background data-[state=open]:animate-fd-sidebar-in data-[state=closed]:animate-fd-sidebar-out',
|
||||
!present && 'invisible',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
)}
|
||||
</Presence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarHeader(props: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn('flex flex-col gap-3 p-4 pb-2', props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarFooter(props: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn('flex flex-col border-t p-4 pt-2', props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarViewport(props: ScrollAreaProps) {
|
||||
return (
|
||||
<ScrollArea {...props} className={cn('min-h-0 flex-1', props.className)}>
|
||||
<ScrollViewport
|
||||
className="p-4 overscroll-contain"
|
||||
style={
|
||||
{
|
||||
'--sidebar-item-offset': 'calc(var(--spacing) * 2)',
|
||||
maskImage:
|
||||
'linear-gradient(to bottom, transparent, white 12px, white calc(100% - 12px), transparent)',
|
||||
} as object
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</ScrollViewport>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarSeparator(props: ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
{...props}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 mb-1.5 px-2 ps-(--sidebar-item-offset) empty:mb-0 [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarItem({
|
||||
icon,
|
||||
...props
|
||||
}: LinkProps & {
|
||||
icon?: ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const active =
|
||||
props.href !== undefined && isActive(props.href, pathname, false);
|
||||
const { prefetch } = useInternalContext();
|
||||
|
||||
return (
|
||||
<Link
|
||||
{...props}
|
||||
data-active={active}
|
||||
className={cn(itemVariants({ active }), props.className)}
|
||||
prefetch={prefetch}
|
||||
>
|
||||
{icon ?? (props.external ? <ExternalLink /> : null)}
|
||||
{props.children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarFolder({
|
||||
defaultOpen = false,
|
||||
...props
|
||||
}: ComponentProps<'div'> & {
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
useOnChange(defaultOpen, (v) => {
|
||||
if (v) setOpen(v);
|
||||
});
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} {...props}>
|
||||
<FolderContext.Provider
|
||||
value={useMemo(() => ({ open, setOpen }), [open])}
|
||||
>
|
||||
{props.children}
|
||||
</FolderContext.Provider>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarFolderTrigger({
|
||||
className,
|
||||
...props
|
||||
}: CollapsibleTriggerProps) {
|
||||
const { open } = useFolderContext();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(itemVariants({ active: false }), 'w-full', className)}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
<ChevronDown
|
||||
data-icon
|
||||
className={cn('ms-auto transition-transform', !open && '-rotate-90')}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarFolderLink(props: LinkProps) {
|
||||
const { open, setOpen } = useFolderContext();
|
||||
const { prefetch } = useInternalContext();
|
||||
|
||||
const pathname = usePathname();
|
||||
const active =
|
||||
props.href !== undefined && isActive(props.href, pathname, false);
|
||||
|
||||
return (
|
||||
<Link
|
||||
{...props}
|
||||
data-active={active}
|
||||
className={cn(itemVariants({ active }), 'w-full', props.className)}
|
||||
onClick={(e) => {
|
||||
if (
|
||||
e.target instanceof Element &&
|
||||
e.target.matches('[data-icon], [data-icon] *')
|
||||
) {
|
||||
setOpen(!open);
|
||||
e.preventDefault();
|
||||
} else {
|
||||
setOpen(active ? !open : true);
|
||||
}
|
||||
}}
|
||||
prefetch={prefetch}
|
||||
>
|
||||
{props.children}
|
||||
<ChevronDown
|
||||
data-icon
|
||||
className={cn('ms-auto transition-transform', !open && '-rotate-90')}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarFolderContent(props: CollapsibleContentProps) {
|
||||
const { level, ...ctx } = useInternalContext();
|
||||
|
||||
return (
|
||||
<CollapsibleContent
|
||||
{...props}
|
||||
className={cn(
|
||||
'relative',
|
||||
level === 1 && [
|
||||
"before:content-[''] before:absolute before:w-px before:inset-y-1 before:bg-fd-border before:start-2.5",
|
||||
"**:data-[active=true]:before:content-[''] **:data-[active=true]:before:bg-fd-primary **:data-[active=true]:before:absolute **:data-[active=true]:before:w-px **:data-[active=true]:before:inset-y-2.5 **:data-[active=true]:before:start-2.5",
|
||||
],
|
||||
props.className,
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--sidebar-item-offset': `calc(var(--spacing) * ${(level + 1) * 3})`,
|
||||
...props.style,
|
||||
} as object
|
||||
}
|
||||
>
|
||||
<Context.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
...ctx,
|
||||
level: level + 1,
|
||||
}),
|
||||
[ctx, level],
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</Context.Provider>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarTrigger({
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<'button'>) {
|
||||
const { setOpen } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
aria-label="Open Sidebar"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarCollapseTrigger(props: ComponentProps<'button'>) {
|
||||
const { collapsed, setCollapsed } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Collapse Sidebar"
|
||||
data-collapsed={collapsed}
|
||||
{...props}
|
||||
onClick={() => {
|
||||
setCollapsed((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function useFolderContext() {
|
||||
const ctx = use(FolderContext);
|
||||
if (!ctx) throw new Error('Missing sidebar folder');
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function useInternalContext() {
|
||||
const ctx = use(Context);
|
||||
if (!ctx) throw new Error('<Sidebar /> component required.');
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export interface SidebarComponents {
|
||||
Item: FC<{ item: PageTree.Item }>;
|
||||
Folder: FC<{ item: PageTree.Folder; level: number; children: ReactNode }>;
|
||||
Separator: FC<{ item: PageTree.Separator }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render sidebar items from page tree
|
||||
*/
|
||||
export function SidebarPageTree(props: {
|
||||
components?: Partial<SidebarComponents>;
|
||||
}) {
|
||||
const { root } = useTreeContext();
|
||||
|
||||
return useMemo(() => {
|
||||
const { Separator, Item, Folder } = props.components ?? {};
|
||||
|
||||
function renderSidebarList(
|
||||
items: PageTree.Node[],
|
||||
level: number,
|
||||
): ReactNode[] {
|
||||
return items.map((item, i) => {
|
||||
if (item.type === 'separator') {
|
||||
if (Separator) return <Separator key={i} item={item} />;
|
||||
return (
|
||||
<SidebarSeparator key={i} className={cn(i !== 0 && 'mt-6')}>
|
||||
{item.icon}
|
||||
{item.name}
|
||||
</SidebarSeparator>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'folder') {
|
||||
const children = renderSidebarList(item.children, level + 1);
|
||||
|
||||
if (Folder)
|
||||
return (
|
||||
<Folder key={i} item={item} level={level}>
|
||||
{children}
|
||||
</Folder>
|
||||
);
|
||||
return (
|
||||
<PageTreeFolder key={i} item={item}>
|
||||
{children}
|
||||
</PageTreeFolder>
|
||||
);
|
||||
}
|
||||
|
||||
if (Item) return <Item key={item.url} item={item} />;
|
||||
return (
|
||||
<SidebarItem
|
||||
key={item.url}
|
||||
href={item.url}
|
||||
external={item.external}
|
||||
icon={item.icon}
|
||||
>
|
||||
{item.name}
|
||||
</SidebarItem>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={root.$id}>{renderSidebarList(root.children, 1)}</Fragment>
|
||||
);
|
||||
}, [props.components, root]);
|
||||
}
|
||||
|
||||
function PageTreeFolder({
|
||||
item,
|
||||
...props
|
||||
}: {
|
||||
item: PageTree.Folder;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { defaultOpenLevel, level } = useInternalContext();
|
||||
const path = useTreePath();
|
||||
|
||||
return (
|
||||
<SidebarFolder
|
||||
defaultOpen={
|
||||
(item.defaultOpen ?? defaultOpenLevel >= level) || path.includes(item)
|
||||
}
|
||||
>
|
||||
{item.index ? (
|
||||
<SidebarFolderLink
|
||||
href={item.index.url}
|
||||
external={item.index.external}
|
||||
{...props}
|
||||
>
|
||||
{item.icon}
|
||||
{item.name}
|
||||
</SidebarFolderLink>
|
||||
) : (
|
||||
<SidebarFolderTrigger {...props}>
|
||||
{item.icon}
|
||||
{item.name}
|
||||
</SidebarFolderTrigger>
|
||||
)}
|
||||
<SidebarFolderContent>{props.children}</SidebarFolderContent>
|
||||
</SidebarFolder>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarLinkItem({
|
||||
item,
|
||||
...props
|
||||
}: {
|
||||
item: Exclude<LinkItemType, { type: 'icon' }>;
|
||||
className?: string;
|
||||
}) {
|
||||
if (item.type === 'menu')
|
||||
return (
|
||||
<SidebarFolder {...props}>
|
||||
{item.url ? (
|
||||
<SidebarFolderLink href={item.url} external={item.external}>
|
||||
{item.icon}
|
||||
{item.text}
|
||||
</SidebarFolderLink>
|
||||
) : (
|
||||
<SidebarFolderTrigger>
|
||||
{item.icon}
|
||||
{item.text}
|
||||
</SidebarFolderTrigger>
|
||||
)}
|
||||
<SidebarFolderContent>
|
||||
{item.items.map((child, i) => (
|
||||
<SidebarLinkItem key={i} item={child} />
|
||||
))}
|
||||
</SidebarFolderContent>
|
||||
</SidebarFolder>
|
||||
);
|
||||
|
||||
if (item.type === 'custom') return <div {...props}>{item.children}</div>;
|
||||
|
||||
return (
|
||||
<SidebarItem
|
||||
href={item.url}
|
||||
icon={item.icon}
|
||||
external={item.external}
|
||||
{...props}
|
||||
>
|
||||
{item.text}
|
||||
</SidebarItem>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
import { type ComponentProps, Fragment, useState } from 'react';
|
||||
import { type ComponentProps, Fragment, useMemo, useState } from 'react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { BaseLinkItem, type LinkItemType } from '@/layouts/shared';
|
||||
import { resolveLinkItems, type LinkItemType } from '@/layouts/shared';
|
||||
import { LinkItem } from '@/layouts/shared/link-item';
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
@@ -15,6 +16,17 @@ import {
|
||||
} from '@/components/ui/navigation-menu';
|
||||
import { useNav } from '@/contexts/layout';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import type { HomeLayoutProps } from '.';
|
||||
import {
|
||||
LargeSearchToggle,
|
||||
SearchToggle,
|
||||
} from '@/layouts/shared/search-toggle';
|
||||
import { ThemeToggle } from '@/layouts/shared/theme-toggle';
|
||||
import {
|
||||
LanguageToggle,
|
||||
LanguageToggleText,
|
||||
} from '@/layouts/shared/language-toggle';
|
||||
import { ChevronDown, Languages } from 'lucide-react';
|
||||
|
||||
export const navItemVariants = cva('[&_svg]:size-4', {
|
||||
variants: {
|
||||
@@ -35,7 +47,144 @@ export const navItemVariants = cva('[&_svg]:size-4', {
|
||||
},
|
||||
});
|
||||
|
||||
export function Navbar(props: ComponentProps<'div'>) {
|
||||
export function Header({
|
||||
nav = {},
|
||||
i18n = false,
|
||||
links,
|
||||
githubUrl,
|
||||
themeSwitch = {},
|
||||
searchToggle = {},
|
||||
}: HomeLayoutProps) {
|
||||
const { navItems, menuItems } = useMemo(() => {
|
||||
const navItems: LinkItemType[] = [];
|
||||
const menuItems: LinkItemType[] = [];
|
||||
|
||||
for (const item of resolveLinkItems({ links, githubUrl })) {
|
||||
switch (item.on ?? 'all') {
|
||||
case 'menu':
|
||||
menuItems.push(item);
|
||||
break;
|
||||
case 'nav':
|
||||
navItems.push(item);
|
||||
break;
|
||||
default:
|
||||
navItems.push(item);
|
||||
menuItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return { navItems, menuItems };
|
||||
}, [links, githubUrl]);
|
||||
|
||||
return (
|
||||
<HeaderNavigationMenu>
|
||||
<Link
|
||||
href={nav.url ?? '/'}
|
||||
className="inline-flex items-center gap-2.5 font-semibold"
|
||||
>
|
||||
{nav.title}
|
||||
</Link>
|
||||
{nav.children}
|
||||
<ul className="flex flex-row items-center gap-2 px-6 max-sm:hidden">
|
||||
{navItems
|
||||
.filter((item) => !isSecondary(item))
|
||||
.map((item, i) => (
|
||||
<NavigationMenuLinkItem key={i} item={item} className="text-sm" />
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex flex-row items-center justify-end gap-1.5 flex-1 max-lg:hidden">
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.lg ?? (
|
||||
<LargeSearchToggle
|
||||
className="w-full rounded-full ps-2.5 max-w-[240px]"
|
||||
hideIfDisabled
|
||||
/>
|
||||
))}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? <ThemeToggle mode={themeSwitch?.mode} />)}
|
||||
{i18n && (
|
||||
<LanguageToggle>
|
||||
<Languages className="size-5" />
|
||||
</LanguageToggle>
|
||||
)}
|
||||
<ul className="flex flex-row gap-2 items-center empty:hidden">
|
||||
{navItems.filter(isSecondary).map((item, i) => (
|
||||
<NavigationMenuLinkItem
|
||||
key={i}
|
||||
className={cn(
|
||||
item.type === 'icon' && '-mx-1 first:ms-0 last:me-0',
|
||||
)}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<ul className="flex flex-row items-center ms-auto -me-1.5 lg:hidden">
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.sm ?? (
|
||||
<SearchToggle className="p-2" hideIfDisabled />
|
||||
))}
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger
|
||||
aria-label="Toggle Menu"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: 'icon',
|
||||
color: 'ghost',
|
||||
className: 'group [&_svg]:size-5.5',
|
||||
}),
|
||||
)}
|
||||
onPointerMove={
|
||||
nav.enableHoverToOpen ? undefined : (e) => e.preventDefault()
|
||||
}
|
||||
>
|
||||
<ChevronDown className="transition-transform duration-300 group-data-[state=open]:rotate-180" />
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent className="flex flex-col p-4 sm:flex-row sm:items-center sm:justify-end">
|
||||
{menuItems
|
||||
.filter((item) => !isSecondary(item))
|
||||
.map((item, i) => (
|
||||
<MobileNavigationMenuLinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className="sm:hidden"
|
||||
/>
|
||||
))}
|
||||
<div className="-ms-1.5 flex flex-row items-center gap-2 max-sm:mt-2">
|
||||
{menuItems.filter(isSecondary).map((item, i) => (
|
||||
<MobileNavigationMenuLinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(item.type === 'icon' && '-mx-1 first:ms-0')}
|
||||
/>
|
||||
))}
|
||||
<div role="separator" className="flex-1" />
|
||||
{i18n && (
|
||||
<LanguageToggle>
|
||||
<Languages className="size-5" />
|
||||
<LanguageToggleText />
|
||||
<ChevronDown className="size-3 text-fd-muted-foreground" />
|
||||
</LanguageToggle>
|
||||
)}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? (
|
||||
<ThemeToggle mode={themeSwitch?.mode} />
|
||||
))}
|
||||
</div>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
</ul>
|
||||
</HeaderNavigationMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function isSecondary(item: LinkItemType): boolean {
|
||||
if ('secondary' in item && item.secondary != null) return item.secondary;
|
||||
|
||||
return item.type === 'icon';
|
||||
}
|
||||
|
||||
function HeaderNavigationMenu(props: ComponentProps<'div'>) {
|
||||
const [value, setValue] = useState('');
|
||||
const { isTransparent } = useNav();
|
||||
|
||||
@@ -44,29 +193,30 @@ export function Navbar(props: ComponentProps<'div'>) {
|
||||
<header
|
||||
id="nd-nav"
|
||||
{...props}
|
||||
className={cn(
|
||||
'fixed top-(--fd-banner-height) z-40 left-0 right-(--removed-body-scroll-bar-size,0) backdrop-blur-lg border-b transition-colors *:mx-auto *:max-w-fd-container',
|
||||
value.length > 0 && 'max-lg:shadow-lg max-lg:rounded-b-2xl',
|
||||
(!isTransparent || value.length > 0) && 'bg-fd-background/80',
|
||||
props.className,
|
||||
)}
|
||||
className={cn('sticky h-14 top-0 z-40', props.className)}
|
||||
>
|
||||
<NavigationMenuList
|
||||
className="flex h-14 w-full items-center px-4"
|
||||
asChild
|
||||
<div
|
||||
className={cn(
|
||||
'backdrop-blur-lg border-b transition-colors *:mx-auto *:max-w-fd-container',
|
||||
value.length > 0 && 'max-lg:shadow-lg max-lg:rounded-b-2xl',
|
||||
(!isTransparent || value.length > 0) && 'bg-fd-background/80',
|
||||
)}
|
||||
>
|
||||
<nav>{props.children}</nav>
|
||||
</NavigationMenuList>
|
||||
<NavigationMenuList
|
||||
className="flex h-14 w-full items-center px-4"
|
||||
asChild
|
||||
>
|
||||
<nav>{props.children}</nav>
|
||||
</NavigationMenuList>
|
||||
|
||||
<NavigationMenuViewport />
|
||||
<NavigationMenuViewport />
|
||||
</div>
|
||||
</header>
|
||||
</NavigationMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export { NavigationMenuItem };
|
||||
|
||||
export function NavigationMenuLinkItem({
|
||||
function NavigationMenuLinkItem({
|
||||
item,
|
||||
...props
|
||||
}: {
|
||||
@@ -136,19 +286,19 @@ export function NavigationMenuLinkItem({
|
||||
return (
|
||||
<NavigationMenuItem {...props}>
|
||||
<NavigationMenuLink asChild>
|
||||
<BaseLinkItem
|
||||
<LinkItem
|
||||
item={item}
|
||||
aria-label={item.type === 'icon' ? item.label : undefined}
|
||||
className={cn(navItemVariants({ variant: item.type }))}
|
||||
>
|
||||
{item.type === 'icon' ? item.icon : item.text}
|
||||
</BaseLinkItem>
|
||||
</LinkItem>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileNavigationMenuLinkItem({
|
||||
function MobileNavigationMenuLinkItem({
|
||||
item,
|
||||
...props
|
||||
}: {
|
||||
@@ -188,7 +338,7 @@ export function MobileNavigationMenuLinkItem({
|
||||
|
||||
return (
|
||||
<NavigationMenuLink asChild>
|
||||
<BaseLinkItem
|
||||
<LinkItem
|
||||
item={item}
|
||||
className={cn(
|
||||
{
|
||||
@@ -208,39 +358,7 @@ export function MobileNavigationMenuLinkItem({
|
||||
>
|
||||
{item.icon}
|
||||
{item.type === 'icon' ? undefined : item.text}
|
||||
</BaseLinkItem>
|
||||
</LinkItem>
|
||||
</NavigationMenuLink>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileNavigationMenuTrigger({
|
||||
enableHover = false,
|
||||
...props
|
||||
}: ComponentProps<typeof NavigationMenuTrigger> & {
|
||||
/**
|
||||
* Enable hover to trigger
|
||||
*/
|
||||
enableHover?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuTrigger
|
||||
{...props}
|
||||
onPointerMove={enableHover ? undefined : (e) => e.preventDefault()}
|
||||
>
|
||||
{props.children}
|
||||
</NavigationMenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileNavigationMenuContent(
|
||||
props: ComponentProps<typeof NavigationMenuContent>,
|
||||
) {
|
||||
return (
|
||||
<NavigationMenuContent
|
||||
{...props}
|
||||
className={cn('flex flex-col p-4', props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</NavigationMenuContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,8 @@
|
||||
import { type HTMLAttributes, useMemo } from 'react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import {
|
||||
type BaseLayoutProps,
|
||||
getLinks,
|
||||
type LinkItemType,
|
||||
type NavOptions,
|
||||
} from '@/layouts/shared';
|
||||
import { type BaseLayoutProps, type NavOptions } from '@/layouts/shared';
|
||||
import { NavProvider } from '@/contexts/layout';
|
||||
import {
|
||||
LargeSearchToggle,
|
||||
SearchToggle,
|
||||
} from '@/components/layout/search-toggle';
|
||||
import { ThemeToggle } from '@/components/layout/theme-toggle';
|
||||
import {
|
||||
LanguageToggle,
|
||||
LanguageToggleText,
|
||||
} from '@/components/layout/language-toggle';
|
||||
import { ChevronDown, Languages } from 'lucide-react';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import {
|
||||
Navbar,
|
||||
NavigationMenuLinkItem,
|
||||
MobileNavigationMenuContent,
|
||||
MobileNavigationMenuLinkItem,
|
||||
MobileNavigationMenuTrigger,
|
||||
NavigationMenuItem,
|
||||
} from '@/layouts/home/client';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { Header } from '@/layouts/home/client';
|
||||
|
||||
export interface HomeLayoutProps extends BaseLayoutProps {
|
||||
nav?: Partial<
|
||||
@@ -39,9 +15,7 @@ export interface HomeLayoutProps extends BaseLayoutProps {
|
||||
>;
|
||||
}
|
||||
|
||||
export function HomeLayout(
|
||||
props: HomeLayoutProps & HTMLAttributes<HTMLElement>,
|
||||
) {
|
||||
export function HomeLayout(props: HomeLayoutProps & ComponentProps<'main'>) {
|
||||
const {
|
||||
nav = {},
|
||||
links,
|
||||
@@ -57,7 +31,7 @@ export function HomeLayout(
|
||||
<main
|
||||
id="nd-home-layout"
|
||||
{...rest}
|
||||
className={cn('flex flex-1 flex-col pt-14', rest.className)}
|
||||
className={cn('flex flex-1 flex-col', rest.className)}
|
||||
>
|
||||
{nav.enabled !== false &&
|
||||
(nav.component ?? (
|
||||
@@ -75,129 +49,3 @@ export function HomeLayout(
|
||||
</NavProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header({
|
||||
nav = {},
|
||||
i18n = false,
|
||||
links,
|
||||
githubUrl,
|
||||
themeSwitch = {},
|
||||
searchToggle = {},
|
||||
}: HomeLayoutProps) {
|
||||
const finalLinks = useMemo(
|
||||
() => getLinks(links, githubUrl),
|
||||
[links, githubUrl],
|
||||
);
|
||||
|
||||
const navItems = finalLinks.filter((item) =>
|
||||
['nav', 'all'].includes(item.on ?? 'all'),
|
||||
);
|
||||
const menuItems = finalLinks.filter((item) =>
|
||||
['menu', 'all'].includes(item.on ?? 'all'),
|
||||
);
|
||||
|
||||
return (
|
||||
<Navbar>
|
||||
<Link
|
||||
href={nav.url ?? '/'}
|
||||
className="inline-flex items-center gap-2.5 font-semibold"
|
||||
>
|
||||
{nav.title}
|
||||
</Link>
|
||||
{nav.children}
|
||||
<ul className="flex flex-row items-center gap-2 px-6 max-sm:hidden">
|
||||
{navItems
|
||||
.filter((item) => !isSecondary(item))
|
||||
.map((item, i) => (
|
||||
<NavigationMenuLinkItem key={i} item={item} className="text-sm" />
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex flex-row items-center justify-end gap-1.5 flex-1 max-lg:hidden">
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.lg ?? (
|
||||
<LargeSearchToggle
|
||||
className="w-full rounded-full ps-2.5 max-w-[240px]"
|
||||
hideIfDisabled
|
||||
/>
|
||||
))}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? <ThemeToggle mode={themeSwitch?.mode} />)}
|
||||
{i18n && (
|
||||
<LanguageToggle>
|
||||
<Languages className="size-5" />
|
||||
</LanguageToggle>
|
||||
)}
|
||||
<ul className="flex flex-row gap-2 items-center empty:hidden">
|
||||
{navItems.filter(isSecondary).map((item, i) => (
|
||||
<NavigationMenuLinkItem
|
||||
key={i}
|
||||
className={cn(
|
||||
item.type === 'icon' && '-mx-1 first:ms-0 last:me-0',
|
||||
)}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<ul className="flex flex-row items-center ms-auto -me-1.5 lg:hidden">
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.sm ?? (
|
||||
<SearchToggle className="p-2" hideIfDisabled />
|
||||
))}
|
||||
<NavigationMenuItem>
|
||||
<MobileNavigationMenuTrigger
|
||||
aria-label="Toggle Menu"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: 'icon',
|
||||
color: 'ghost',
|
||||
className: 'group [&_svg]:size-5.5',
|
||||
}),
|
||||
)}
|
||||
enableHover={nav.enableHoverToOpen}
|
||||
>
|
||||
<ChevronDown className="transition-transform duration-300 group-data-[state=open]:rotate-180" />
|
||||
</MobileNavigationMenuTrigger>
|
||||
<MobileNavigationMenuContent className="sm:flex-row sm:items-center sm:justify-end">
|
||||
{menuItems
|
||||
.filter((item) => !isSecondary(item))
|
||||
.map((item, i) => (
|
||||
<MobileNavigationMenuLinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className="sm:hidden"
|
||||
/>
|
||||
))}
|
||||
<div className="-ms-1.5 flex flex-row items-center gap-2 max-sm:mt-2">
|
||||
{menuItems.filter(isSecondary).map((item, i) => (
|
||||
<MobileNavigationMenuLinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(item.type === 'icon' && '-mx-1 first:ms-0')}
|
||||
/>
|
||||
))}
|
||||
<div role="separator" className="flex-1" />
|
||||
{i18n && (
|
||||
<LanguageToggle>
|
||||
<Languages className="size-5" />
|
||||
<LanguageToggleText />
|
||||
<ChevronDown className="size-3 text-fd-muted-foreground" />
|
||||
</LanguageToggle>
|
||||
)}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? (
|
||||
<ThemeToggle mode={themeSwitch?.mode} />
|
||||
))}
|
||||
</div>
|
||||
</MobileNavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
</ul>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
|
||||
function isSecondary(item: LinkItemType): boolean {
|
||||
if ('secondary' in item && item.secondary != null) return item.secondary;
|
||||
|
||||
return item.type === 'icon';
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Sidebar as SidebarIcon } from 'lucide-react';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import { usePathname } from 'fumadocs-core/framework';
|
||||
import { isTabActive } from '@/utils/is-active';
|
||||
import type { Option } from '@/components/layout/root-toggle';
|
||||
import type { Option } from '@/layouts/shared/root-toggle';
|
||||
|
||||
export function Navbar({
|
||||
mode,
|
||||
@@ -40,10 +40,10 @@ export function LayoutBody(props: ComponentProps<'main'>) {
|
||||
|
||||
return (
|
||||
<main
|
||||
id="nd-docs-layout"
|
||||
id="nd-notebook-layout"
|
||||
{...props}
|
||||
className={cn(
|
||||
'flex flex-1 flex-col transition-[padding] pt-(--fd-nav-height) fd-notebook-layout',
|
||||
'flex flex-1 flex-col transition-[padding] pt-(--fd-nav-height)',
|
||||
!collapsed && 'mx-(--fd-layout-offset)',
|
||||
props.className,
|
||||
)}
|
||||
|
||||
@@ -6,13 +6,7 @@ import {
|
||||
type ReactNode,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import {
|
||||
type BaseLayoutProps,
|
||||
BaseLinkItem,
|
||||
type BaseLinkType,
|
||||
getLinks,
|
||||
type LinkItemType,
|
||||
} from '@/layouts/shared';
|
||||
import { type BaseLayoutProps, resolveLinkItems } from '@/layouts/shared';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarCollapseTrigger,
|
||||
@@ -30,7 +24,7 @@ import {
|
||||
type SidebarProps,
|
||||
SidebarTrigger,
|
||||
SidebarViewport,
|
||||
} from '@/components/layout/sidebar';
|
||||
} from './sidebar';
|
||||
import { TreeContextProvider } from '@/contexts/tree';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
@@ -40,8 +34,8 @@ import {
|
||||
Sidebar as SidebarIcon,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { LanguageToggle } from '@/components/layout/language-toggle';
|
||||
import { ThemeToggle } from '@/components/layout/theme-toggle';
|
||||
import { LanguageToggle } from '@/layouts/shared/language-toggle';
|
||||
import { ThemeToggle } from '@/layouts/shared/theme-toggle';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -55,16 +49,17 @@ import {
|
||||
NavbarSidebarTrigger,
|
||||
} from '@/layouts/notebook/client';
|
||||
import { NavProvider } from '@/contexts/layout';
|
||||
import { type Option, RootToggle } from '@/components/layout/root-toggle';
|
||||
import { type Option, RootToggle } from '@/layouts/shared/root-toggle';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import {
|
||||
LargeSearchToggle,
|
||||
SearchToggle,
|
||||
} from '@/components/layout/search-toggle';
|
||||
} from '@/layouts/shared/search-toggle';
|
||||
import {
|
||||
getSidebarTabs,
|
||||
type GetSidebarTabsOptions,
|
||||
} from '@/utils/get-sidebar-tabs';
|
||||
import { LinkItem, type LinkItemType } from '@/layouts/shared/link-item';
|
||||
|
||||
export interface DocsLayoutProps extends BaseLayoutProps {
|
||||
tree: PageTree.Root;
|
||||
@@ -110,7 +105,7 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
} = props;
|
||||
|
||||
const navMode = nav.mode ?? 'auto';
|
||||
const links = getLinks(props.links ?? [], props.githubUrl);
|
||||
const links = resolveLinkItems(props);
|
||||
const tabs = useMemo(() => {
|
||||
if (Array.isArray(tabOptions)) {
|
||||
return tabOptions;
|
||||
@@ -229,7 +224,7 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
)}
|
||||
>
|
||||
{iconLinks.map((item, i) => (
|
||||
<BaseLinkItem
|
||||
<LinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(
|
||||
@@ -242,7 +237,7 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
aria-label={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
</BaseLinkItem>
|
||||
</LinkItem>
|
||||
))}
|
||||
</Footer>
|
||||
</SidebarContent>
|
||||
@@ -273,7 +268,7 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
)}
|
||||
>
|
||||
{iconLinks.map((item, i) => (
|
||||
<BaseLinkItem
|
||||
<LinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(
|
||||
@@ -287,7 +282,7 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
aria-label={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
</BaseLinkItem>
|
||||
</LinkItem>
|
||||
))}
|
||||
{i18n && (
|
||||
<LanguageToggle>
|
||||
@@ -353,8 +348,8 @@ function DocsNavbar({
|
||||
<Navbar
|
||||
mode={navMode}
|
||||
className={cn(
|
||||
'on-root:[--fd-nav-height:56px] md:on-root:[--fd-nav-height:64px]',
|
||||
tabs.length > 0 && 'lg:on-root:[--fd-nav-height:104px]',
|
||||
'on-notebook-layout:[--fd-nav-height:56px] md:on-notebook-layout:[--fd-nav-height:64px]',
|
||||
tabs.length > 0 && 'lg:on-notebook-layout:[--fd-nav-height:104px]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -433,7 +428,7 @@ function DocsNavbar({
|
||||
{links
|
||||
.filter((item) => item.type === 'icon')
|
||||
.map((item, i) => (
|
||||
<BaseLinkItem
|
||||
<LinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(
|
||||
@@ -443,7 +438,7 @@ function DocsNavbar({
|
||||
aria-label={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
</BaseLinkItem>
|
||||
</LinkItem>
|
||||
))}
|
||||
|
||||
<div className="flex items-center md:hidden">
|
||||
@@ -508,7 +503,7 @@ function NavbarLinkItem({
|
||||
)}
|
||||
>
|
||||
{item.url ? (
|
||||
<BaseLinkItem item={item as BaseLinkType}>{item.text}</BaseLinkItem>
|
||||
<LinkItem item={item as { url: string }}>{item.text}</LinkItem>
|
||||
) : (
|
||||
item.text
|
||||
)}
|
||||
@@ -520,14 +515,14 @@ function NavbarLinkItem({
|
||||
return <Fragment key={i}>{child.children}</Fragment>;
|
||||
|
||||
return (
|
||||
<BaseLinkItem
|
||||
<LinkItem
|
||||
key={i}
|
||||
item={child}
|
||||
className="inline-flex items-center gap-2 rounded-md p-2 text-start hover:bg-fd-accent hover:text-fd-accent-foreground data-[active=true]:text-fd-primary [&_svg]:size-4"
|
||||
>
|
||||
{child.icon}
|
||||
{child.text}
|
||||
</BaseLinkItem>
|
||||
</LinkItem>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
@@ -538,9 +533,9 @@ function NavbarLinkItem({
|
||||
if (item.type === 'custom') return item.children;
|
||||
|
||||
return (
|
||||
<BaseLinkItem item={item} {...props}>
|
||||
<LinkItem item={item} {...props}>
|
||||
{item.text}
|
||||
</BaseLinkItem>
|
||||
</LinkItem>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -30,7 +30,7 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { useSidebar } from '@/contexts/sidebar';
|
||||
import { useTOCItems } from '@/components/layout/toc';
|
||||
import { useTOCItems } from '@/components/toc';
|
||||
import { useActiveAnchor } from 'fumadocs-core/toc';
|
||||
|
||||
const TocPopoverContext = createContext<{
|
||||
@@ -199,7 +199,7 @@ export function PageTOCPopover(props: ComponentProps<'div'>) {
|
||||
id="nd-tocnav"
|
||||
{...props}
|
||||
className={cn(
|
||||
'fixed pr-(--removed-body-scroll-bar-size,0) z-10 border-b backdrop-blur-sm transition-colors xl:hidden max-xl:on-root:[--fd-tocnav-height:40px]',
|
||||
'fixed pr-(--removed-body-scroll-bar-size,0) z-10 border-b backdrop-blur-sm transition-colors xl:hidden max-xl:on-notebook-layout:[--fd-tocnav-height:40px]',
|
||||
(!isTransparent || open) && 'bg-fd-background/80',
|
||||
open && 'shadow-lg',
|
||||
props.className,
|
||||
@@ -402,7 +402,7 @@ export function PageTOC(props: ComponentProps<'div'>) {
|
||||
id="nd-toc"
|
||||
{...props}
|
||||
className={cn(
|
||||
'fixed bottom-0 pt-12 pb-2 pr-(--removed-body-scroll-bar-size,0) xl:on-root:[--fd-toc-width:286px] max-xl:hidden',
|
||||
'fixed bottom-0 pt-12 pb-2 pr-(--removed-body-scroll-bar-size,0) max-xl:hidden',
|
||||
props.className,
|
||||
)}
|
||||
style={{
|
||||
@@ -3,23 +3,22 @@ import { cn } from '@/utils/cn';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { Edit } from '@/icons';
|
||||
import { I18nLabel } from '@/contexts/i18n';
|
||||
import type { AnchorProviderProps, TOCItemType } from 'fumadocs-core/toc';
|
||||
import { TOCProvider, TOCScrollArea } from '@/components/toc';
|
||||
import * as TocDefault from '@/components/toc/default';
|
||||
import * as TocClerk from '@/components/toc/clerk';
|
||||
import { Text } from 'lucide-react';
|
||||
import {
|
||||
type BreadcrumbProps,
|
||||
type FooterProps,
|
||||
PageArticle,
|
||||
BreadcrumbProps,
|
||||
FooterProps,
|
||||
PageBreadcrumb,
|
||||
PageFooter,
|
||||
PageLastUpdate,
|
||||
PageRoot,
|
||||
PageTOC,
|
||||
PageTOCItems,
|
||||
PageTOCPopover,
|
||||
PageTOCPopoverContent,
|
||||
PageTOCPopoverItems,
|
||||
PageTOCPopoverTrigger,
|
||||
PageTOCTitle,
|
||||
} from '@/layouts/docs/page';
|
||||
import type { AnchorProviderProps, TOCItemType } from 'fumadocs-core/toc';
|
||||
} from './client';
|
||||
|
||||
interface EditOnGitHubOptions
|
||||
extends Omit<ComponentProps<'a'>, 'href' | 'children'> {
|
||||
@@ -138,17 +137,24 @@ export function DocsPage({
|
||||
tocPopoverOptions.header !== undefined ||
|
||||
tocPopoverOptions.footer !== undefined;
|
||||
|
||||
return (
|
||||
<PageRoot
|
||||
toc={
|
||||
tocEnabled || tocPopoverEnabled
|
||||
? {
|
||||
toc,
|
||||
single: tocOptions.single,
|
||||
}
|
||||
: false
|
||||
}
|
||||
let wrapper = (children: ReactNode) => children;
|
||||
|
||||
if (tocEnabled || tocPopoverEnabled) {
|
||||
wrapper = (children) => (
|
||||
<TOCProvider toc={toc} single={tocOptions.single}>
|
||||
{children}
|
||||
</TOCProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return wrapper(
|
||||
<div
|
||||
id="nd-page"
|
||||
{...container}
|
||||
className={cn(
|
||||
'flex flex-1 w-full mx-auto max-w-(--fd-page-width) pt-(--fd-tocnav-height) pe-(--fd-toc-width)',
|
||||
container?.className,
|
||||
)}
|
||||
>
|
||||
{tocPopoverEnabled &&
|
||||
(tocPopover ?? (
|
||||
@@ -156,12 +162,24 @@ export function DocsPage({
|
||||
<PageTOCPopoverTrigger />
|
||||
<PageTOCPopoverContent>
|
||||
{tocPopoverOptions.header}
|
||||
<PageTOCPopoverItems variant={tocPopoverOptions.style} />
|
||||
<TOCScrollArea>
|
||||
{tocPopoverOptions.style === 'clerk' ? (
|
||||
<TocDefault.TOCItems />
|
||||
) : (
|
||||
<TocClerk.TOCItems />
|
||||
)}
|
||||
</TOCScrollArea>
|
||||
{tocPopoverOptions.footer}
|
||||
</PageTOCPopoverContent>
|
||||
</PageTOCPopover>
|
||||
))}
|
||||
<PageArticle {...article}>
|
||||
<article
|
||||
{...article}
|
||||
className={cn(
|
||||
'flex min-w-0 w-full flex-col gap-4 pt-8 px-4 md:px-6 md:mx-auto',
|
||||
article?.className,
|
||||
)}
|
||||
>
|
||||
{breadcrumbEnabled &&
|
||||
(breadcrumb ?? <PageBreadcrumb {...breadcrumbProps} />)}
|
||||
{children}
|
||||
@@ -175,17 +193,29 @@ export function DocsPage({
|
||||
</div>
|
||||
{footer.enabled !== false &&
|
||||
(footer.component ?? <PageFooter items={footer.items} />)}
|
||||
</PageArticle>
|
||||
</article>
|
||||
{tocEnabled &&
|
||||
(tocReplace ?? (
|
||||
<PageTOC>
|
||||
{tocOptions.header}
|
||||
<PageTOCTitle />
|
||||
<PageTOCItems variant={tocOptions.style} />
|
||||
<h3
|
||||
id="toc-title"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-fd-muted-foreground"
|
||||
>
|
||||
<Text className="size-4" />
|
||||
<I18nLabel label="toc" />
|
||||
</h3>
|
||||
<TOCScrollArea>
|
||||
{tocOptions.style === 'clerk' ? (
|
||||
<TocDefault.TOCItems />
|
||||
) : (
|
||||
<TocClerk.TOCItems />
|
||||
)}
|
||||
</TOCScrollArea>
|
||||
{tocOptions.footer}
|
||||
</PageTOC>
|
||||
))}
|
||||
</PageRoot>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ import {
|
||||
type FC,
|
||||
Fragment,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
use,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -441,14 +441,14 @@ export function SidebarCollapseTrigger(props: ComponentProps<'button'>) {
|
||||
}
|
||||
|
||||
function useFolderContext() {
|
||||
const ctx = useContext(FolderContext);
|
||||
const ctx = use(FolderContext);
|
||||
if (!ctx) throw new Error('Missing sidebar folder');
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function useInternalContext() {
|
||||
const ctx = useContext(Context);
|
||||
const ctx = use(Context);
|
||||
if (!ctx) throw new Error('<Sidebar /> component required.');
|
||||
|
||||
return ctx;
|
||||
@@ -1,30 +0,0 @@
|
||||
'use client';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { usePathname } from 'fumadocs-core/framework';
|
||||
import { isActive } from '@/utils/is-active';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import type { BaseLinkType } from '@/layouts/shared';
|
||||
|
||||
export function BaseLinkItem({
|
||||
ref,
|
||||
item,
|
||||
...props
|
||||
}: Omit<ComponentProps<'a'>, 'href'> & { item: BaseLinkType }) {
|
||||
const pathname = usePathname();
|
||||
const activeType = item.active ?? 'url';
|
||||
const active =
|
||||
activeType !== 'none' &&
|
||||
isActive(item.url, pathname, activeType === 'nested-url');
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
href={item.url}
|
||||
external={item.external}
|
||||
{...props}
|
||||
data-active={active}
|
||||
>
|
||||
{props.children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { NavProviderProps } from '@/contexts/layout';
|
||||
import type { I18nConfig } from 'fumadocs-core/i18n';
|
||||
import type { LinkItemType } from './link-item';
|
||||
|
||||
export interface NavOptions extends NavProviderProps {
|
||||
enabled: boolean;
|
||||
@@ -53,123 +54,30 @@ export interface BaseLayoutProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
interface BaseItem {
|
||||
/**
|
||||
* Restrict where the item is displayed
|
||||
*
|
||||
* @defaultValue 'all'
|
||||
*/
|
||||
on?: 'menu' | 'nav' | 'all';
|
||||
}
|
||||
|
||||
export interface BaseLinkType extends BaseItem {
|
||||
url: string;
|
||||
/**
|
||||
* When the item is marked as active
|
||||
*
|
||||
* @defaultValue 'url'
|
||||
*/
|
||||
active?: 'url' | 'nested-url' | 'none';
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export interface MainItemType extends BaseLinkType {
|
||||
type?: 'main';
|
||||
icon?: ReactNode;
|
||||
text: ReactNode;
|
||||
description?: ReactNode;
|
||||
}
|
||||
|
||||
export interface IconItemType extends BaseLinkType {
|
||||
type: 'icon';
|
||||
/**
|
||||
* `aria-label` of icon button
|
||||
*/
|
||||
label?: string;
|
||||
icon: ReactNode;
|
||||
text: ReactNode;
|
||||
/**
|
||||
* @defaultValue true
|
||||
*/
|
||||
secondary?: boolean;
|
||||
}
|
||||
|
||||
export interface ButtonItemType extends BaseLinkType {
|
||||
type: 'button';
|
||||
icon?: ReactNode;
|
||||
text: ReactNode;
|
||||
/**
|
||||
* @defaultValue false
|
||||
*/
|
||||
secondary?: boolean;
|
||||
}
|
||||
|
||||
export interface MenuItemType extends Partial<BaseLinkType> {
|
||||
type: 'menu';
|
||||
icon?: ReactNode;
|
||||
text: ReactNode;
|
||||
|
||||
items: (
|
||||
| (MainItemType & {
|
||||
/**
|
||||
* Options when displayed on navigation menu
|
||||
*/
|
||||
menu?: HTMLAttributes<HTMLElement> & {
|
||||
banner?: ReactNode;
|
||||
};
|
||||
})
|
||||
| CustomItemType
|
||||
)[];
|
||||
|
||||
/**
|
||||
* @defaultValue false
|
||||
*/
|
||||
secondary?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomItemType extends BaseItem {
|
||||
type: 'custom';
|
||||
/**
|
||||
* @defaultValue false
|
||||
*/
|
||||
secondary?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export type LinkItemType =
|
||||
| MainItemType
|
||||
| IconItemType
|
||||
| ButtonItemType
|
||||
| MenuItemType
|
||||
| CustomItemType;
|
||||
|
||||
/**
|
||||
* Get Links Items with shortcuts
|
||||
* Get link items with shortcuts
|
||||
*/
|
||||
export function getLinks(
|
||||
links: LinkItemType[] = [],
|
||||
githubUrl?: string,
|
||||
): LinkItemType[] {
|
||||
let result = links ?? [];
|
||||
export function resolveLinkItems({
|
||||
links = [],
|
||||
githubUrl,
|
||||
}: Pick<BaseLayoutProps, 'links' | 'githubUrl'>): LinkItemType[] {
|
||||
const result = [...links];
|
||||
|
||||
if (githubUrl)
|
||||
result = [
|
||||
...result,
|
||||
{
|
||||
type: 'icon',
|
||||
url: githubUrl,
|
||||
text: 'Github',
|
||||
label: 'GitHub',
|
||||
icon: (
|
||||
<svg role="img" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
),
|
||||
external: true,
|
||||
},
|
||||
];
|
||||
result.push({
|
||||
type: 'icon',
|
||||
url: githubUrl,
|
||||
text: 'Github',
|
||||
label: 'GitHub',
|
||||
icon: (
|
||||
<svg role="img" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
),
|
||||
external: true,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export { BaseLinkItem } from './client';
|
||||
export type * from './link-item';
|
||||
|
||||
+3
-5
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import { type ButtonHTMLAttributes, type HTMLAttributes } from 'react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useI18n } from '@/contexts/i18n';
|
||||
import {
|
||||
Popover,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { cn } from '@/utils/cn';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
export type LanguageSelectProps = ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
export type LanguageSelectProps = ComponentProps<'button'>;
|
||||
|
||||
export function LanguageToggle(props: LanguageSelectProps): React.ReactElement {
|
||||
const context = useI18n();
|
||||
@@ -56,9 +56,7 @@ export function LanguageToggle(props: LanguageSelectProps): React.ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
export function LanguageToggleText(
|
||||
props: HTMLAttributes<HTMLSpanElement>,
|
||||
): React.ReactElement {
|
||||
export function LanguageToggleText(props: ComponentProps<'span'>) {
|
||||
const context = useI18n();
|
||||
const text = context.locales?.find(
|
||||
(item) => item.locale === context.locale,
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { usePathname } from 'fumadocs-core/framework';
|
||||
import { isActive } from '@/utils/is-active';
|
||||
import Link from 'fumadocs-core/link';
|
||||
|
||||
interface Filterable {
|
||||
/**
|
||||
* Restrict where the item is displayed
|
||||
*
|
||||
* @defaultValue 'all'
|
||||
*/
|
||||
on?: 'menu' | 'nav' | 'all';
|
||||
}
|
||||
|
||||
interface WithHref {
|
||||
url: string;
|
||||
/**
|
||||
* When the item is marked as active
|
||||
*
|
||||
* @defaultValue 'url'
|
||||
*/
|
||||
active?: 'url' | 'nested-url' | 'none';
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export interface MainItemType extends WithHref, Filterable {
|
||||
type?: 'main';
|
||||
icon?: ReactNode;
|
||||
text: ReactNode;
|
||||
description?: ReactNode;
|
||||
}
|
||||
|
||||
export interface IconItemType extends WithHref, Filterable {
|
||||
type: 'icon';
|
||||
/**
|
||||
* `aria-label` of icon button
|
||||
*/
|
||||
label?: string;
|
||||
icon: ReactNode;
|
||||
text: ReactNode;
|
||||
/**
|
||||
* @defaultValue true
|
||||
*/
|
||||
secondary?: boolean;
|
||||
}
|
||||
|
||||
export interface ButtonItemType extends WithHref, Filterable {
|
||||
type: 'button';
|
||||
icon?: ReactNode;
|
||||
text: ReactNode;
|
||||
/**
|
||||
* @defaultValue false
|
||||
*/
|
||||
secondary?: boolean;
|
||||
}
|
||||
|
||||
export interface MenuItemType extends Partial<WithHref>, Filterable {
|
||||
type: 'menu';
|
||||
icon?: ReactNode;
|
||||
text: ReactNode;
|
||||
|
||||
items: (
|
||||
| (MainItemType & {
|
||||
/**
|
||||
* Options when displayed on navigation menu
|
||||
*/
|
||||
menu?: ComponentProps<'a'> & {
|
||||
banner?: ReactNode;
|
||||
};
|
||||
})
|
||||
| CustomItemType
|
||||
)[];
|
||||
|
||||
/**
|
||||
* @defaultValue false
|
||||
*/
|
||||
secondary?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomItemType extends Filterable {
|
||||
type: 'custom';
|
||||
/**
|
||||
* @defaultValue false
|
||||
*/
|
||||
secondary?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export type LinkItemType =
|
||||
| MainItemType
|
||||
| IconItemType
|
||||
| ButtonItemType
|
||||
| MenuItemType
|
||||
| CustomItemType;
|
||||
|
||||
export function LinkItem({
|
||||
ref,
|
||||
item,
|
||||
...props
|
||||
}: Omit<ComponentProps<'a'>, 'href'> & { item: WithHref }) {
|
||||
const pathname = usePathname();
|
||||
const activeType = item.active ?? 'url';
|
||||
const active =
|
||||
activeType !== 'none' &&
|
||||
isActive(item.url, pathname, activeType === 'nested-url');
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
href={item.url}
|
||||
external={item.external}
|
||||
{...props}
|
||||
data-active={active}
|
||||
>
|
||||
{props.children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
+5
-1
@@ -6,7 +6,11 @@ import { usePathname } from 'fumadocs-core/framework';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { isTabActive } from '@/utils/is-active';
|
||||
import { useSidebar } from '@/contexts/sidebar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import type { SidebarTab } from '@/utils/get-sidebar-tabs';
|
||||
|
||||
export interface Option extends SidebarTab {
|
||||
+3
-4
@@ -2,7 +2,7 @@
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { Moon, Sun, Airplay } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { type HTMLAttributes, useLayoutEffect, useState } from 'react';
|
||||
import { ComponentProps, useEffect, useState } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
const itemVariants = cva(
|
||||
@@ -27,13 +27,13 @@ export function ThemeToggle({
|
||||
className,
|
||||
mode = 'light-dark',
|
||||
...props
|
||||
}: HTMLAttributes<HTMLElement> & {
|
||||
}: ComponentProps<'div'> & {
|
||||
mode?: 'light-dark' | 'light-dark-system';
|
||||
}) {
|
||||
const { setTheme, theme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
@@ -51,7 +51,6 @@ export function ThemeToggle({
|
||||
aria-label={`Toggle Theme`}
|
||||
onClick={() => setTheme(value === 'light' ? 'dark' : 'light')}
|
||||
data-theme-toggle=""
|
||||
{...props}
|
||||
>
|
||||
{full.map(([key, Icon]) => {
|
||||
if (key === 'system') return;
|
||||
Reference in New Issue
Block a user