mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
feat(radix-ui): rename pointer layout & port to radix-ui
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
---
|
||||
packages:
|
||||
npm:@fumadocs/base-ui: patch
|
||||
npm:fumadocs-ui: patch
|
||||
npm:fumadocs-core: patch
|
||||
---
|
||||
|
||||
## Introduce Pointer Layout
|
||||
## Introduce Glass Layout
|
||||
|
||||
A new layout for docs, a smooth, beautiful variant.
|
||||
A new layout for docs, a smooth, beautiful variant built around floating, translucent panels.
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
MarkdownCopyButton,
|
||||
ViewOptionsPopover,
|
||||
DocsPageProps,
|
||||
} from 'fumadocs-ui/layouts/pointer/page';
|
||||
} from 'fumadocs-ui/layouts/glass/page';
|
||||
import { NotFound } from '@/components/layouts/not-found';
|
||||
import { getSuggestions } from './suggestions';
|
||||
import { PathUtils } from 'fumadocs-core/source';
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { Pointer as default } from '@/components/layouts/pointer';
|
||||
export { Glass as default } from '@/components/layouts/glass';
|
||||
|
||||
@@ -390,7 +390,7 @@ export function AISearchPanel() {
|
||||
className={cn(
|
||||
'overflow-hidden z-50 bg-fd-card text-fd-card-foreground [--ai-chat-width:400px] 2xl:[--ai-chat-width:460px]',
|
||||
'max-lg:fixed max-lg:inset-x-2 max-lg:inset-y-4 max-lg:border max-lg:rounded-2xl max-lg:shadow-xl',
|
||||
'lg:sticky lg:top-0 lg:h-dvh lg:border-s lg:ms-auto lg:in-[#nd-docs-layout]:[grid-area:toc] lg:in-[#nd-notebook-layout]:row-span-full lg:in-[#fd-pointer-layout]:[grid-area:right] lg:in-[#nd-notebook-layout]:col-start-5',
|
||||
'lg:sticky lg:top-0 lg:h-dvh lg:border-s lg:ms-auto lg:in-[#nd-docs-layout]:[grid-area:toc] lg:in-[#nd-notebook-layout]:row-span-full lg:in-[#fd-glass-layout]:[grid-area:right] lg:in-[#nd-notebook-layout]:col-start-5',
|
||||
open
|
||||
? 'animate-fd-dialog-in lg:animate-[ask-ai-open_200ms] lg:layout:[--fd-right-width:400px]! 2xl:layout:[--fd-right-width:460px]!'
|
||||
: 'animate-fd-dialog-out lg:animate-[ask-ai-close_200ms]',
|
||||
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useAISearchContext } from '@/components/inkeep/search';
|
||||
import { PointerLayout, PointerLayoutProps } from 'fumadocs-ui/layouts/pointer';
|
||||
import { GlassLayout, GlassLayoutProps } from 'fumadocs-ui/layouts/glass';
|
||||
|
||||
export function ClientPointerLayout(props: PointerLayoutProps) {
|
||||
export function ClientGlassLayout(props: GlassLayoutProps) {
|
||||
const { open, setOpen } = useAISearchContext();
|
||||
|
||||
return (
|
||||
<PointerLayout
|
||||
<GlassLayout
|
||||
{...props}
|
||||
aiChat={{
|
||||
open,
|
||||
+4
-4
@@ -4,10 +4,10 @@ import { AISearch, AISearchPanel } from '@/components/inkeep/search';
|
||||
import { getSection } from '@/lib/source/navigation';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { getLayoutTabs } from 'fumadocs-ui/layouts/shared';
|
||||
import { ClientPointerLayout } from './client';
|
||||
import { ClientGlassLayout } from './client';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
export function Pointer({ children }: { children: ReactNode }) {
|
||||
export function Glass({ children }: { children: ReactNode }) {
|
||||
const base = baseOptions();
|
||||
const tabs = getLayoutTabs(source.getPageTree(), {
|
||||
transform(option, node) {
|
||||
@@ -36,7 +36,7 @@ export function Pointer({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<AISearch>
|
||||
<ClientPointerLayout
|
||||
<ClientGlassLayout
|
||||
{...base}
|
||||
tree={source.getPageTree()}
|
||||
// just icon items
|
||||
@@ -54,7 +54,7 @@ export function Pointer({ children }: { children: ReactNode }) {
|
||||
>
|
||||
<AISearchPanel />
|
||||
{children}
|
||||
</ClientPointerLayout>
|
||||
</ClientGlassLayout>
|
||||
</AISearch>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
title: Glass Layout
|
||||
description: A docs layout with a floating, translucent sidebar and header
|
||||
---
|
||||
|
||||
A docs layout built around floating, translucent (glass-like) panels: the sidebar and header sit on top of the content as rounded, blurred surfaces rather than fixed columns.
|
||||
|
||||
<Customization />
|
||||
|
||||
## Usage
|
||||
|
||||
Enable the Glass layout with `fumadocs-ui/layouts/glass`.
|
||||
|
||||
```tsx title="layout.tsx"
|
||||
import { GlassLayout } from 'fumadocs-ui/layouts/glass'; // [!code highlight]
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
import { source } from '@/lib/source';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<GlassLayout {...baseOptions()} tree={source.getPageTree()}>
|
||||
{children}
|
||||
</GlassLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Make sure to update your page import too:
|
||||
|
||||
```tsx title="page.tsx"
|
||||
import { ... } from 'fumadocs-ui/layouts/docs/page'; // [!code --]
|
||||
import { ... } from 'fumadocs-ui/layouts/glass/page'; // [!code ++]
|
||||
```
|
||||
|
||||
<Callout title="Important to know">
|
||||
|
||||
- Glass layout is a **client component**, you cannot pass unserializable props from a server component.
|
||||
- It is opinionated: the sidebar and header are more fixed than the default layout, use Fumadocs CLI or the `slots` option for customization.
|
||||
|
||||
</Callout>
|
||||
|
||||
## Configurations
|
||||
|
||||
The options are inherited from [Docs Layout](/docs/ui/layouts/docs), with minor differences:
|
||||
|
||||
- there is no `sidebar` option, Glass layout is more opinionated than the default one.
|
||||
- additional options (see below).
|
||||
|
||||
### Layout Tabs
|
||||
|
||||
Configure [Layout Tabs](/docs/ui/layouts/docs#layout-tabs) with the `tabs` prop, see the linked docs for how to add them. Tabs are shown as a dropdown in the floating header.
|
||||
|
||||
```tsx title="layout.tsx"
|
||||
import { GlassLayout } from 'fumadocs-ui/layouts/glass';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
import { source } from '@/lib/source';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<GlassLayout
|
||||
{...baseOptions()}
|
||||
tabs={
|
||||
{
|
||||
// customize tabs
|
||||
}
|
||||
}
|
||||
tree={source.getPageTree()}
|
||||
>
|
||||
{children}
|
||||
</GlassLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### AI Chat
|
||||
|
||||
The floating header can render an **Ask AI** button. Pass an `aiChat` object with the open state and a change handler to control your AI search dialog.
|
||||
|
||||
```tsx title="layout.tsx"
|
||||
'use client';
|
||||
import { GlassLayout } from 'fumadocs-ui/layouts/glass';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Layout({ children }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<GlassLayout
|
||||
aiChat={{ open, onOpenChange: setOpen }} // [!code ++]
|
||||
// ...
|
||||
>
|
||||
{children}
|
||||
</GlassLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Slots
|
||||
|
||||
Like other layouts, Glass layout exposes a `slots` option to override the built-in `header`, `sidebar`, and `sidebarDrawer` (mobile) components, in addition to the shared slots (`navTitle`, `themeSwitch`, `searchTrigger`, `languageSelect`).
|
||||
|
||||
```tsx title="layout.tsx"
|
||||
import { GlassLayout } from 'fumadocs-ui/layouts/glass';
|
||||
|
||||
<GlassLayout
|
||||
slots={{
|
||||
header: MyHeader, // [!code ++]
|
||||
}}
|
||||
// ...
|
||||
/>;
|
||||
```
|
||||
@@ -159,7 +159,7 @@
|
||||
#nd-notebook-layout:has(&),
|
||||
#nd-home-layout:has(&),
|
||||
#nd-flux-layout:has(&),
|
||||
#fd-pointer-layout:has(&) {
|
||||
#fd-glass-layout:has(&) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
@import './generated/docs.css';
|
||||
@import './generated/home.css';
|
||||
@import './generated/notebook.css';
|
||||
@import './generated/pointer.css';
|
||||
@import './generated/glass.css';
|
||||
@import './generated/shared.css';
|
||||
|
||||
@@ -73,6 +73,13 @@
|
||||
"./layouts/flux/slots/container": "./dist/layouts/flux/slots/container.js",
|
||||
"./layouts/flux/slots/sidebar": "./dist/layouts/flux/slots/sidebar.js",
|
||||
"./layouts/flux/slots/tab-dropdown": "./dist/layouts/flux/slots/tab-dropdown.js",
|
||||
"./layouts/glass": "./dist/layouts/glass/index.js",
|
||||
"./layouts/glass/page": "./dist/layouts/glass/page/index.js",
|
||||
"./layouts/glass/page/slots/breadcrumb": "./dist/layouts/glass/page/slots/breadcrumb.js",
|
||||
"./layouts/glass/page/slots/footer": "./dist/layouts/glass/page/slots/footer.js",
|
||||
"./layouts/glass/page/slots/toc": "./dist/layouts/glass/page/slots/toc.js",
|
||||
"./layouts/glass/slots/header": "./dist/layouts/glass/slots/header.js",
|
||||
"./layouts/glass/slots/sidebar": "./dist/layouts/glass/slots/sidebar.js",
|
||||
"./layouts/home": "./dist/layouts/home/index.js",
|
||||
"./layouts/home/navbar": "./dist/layouts/home/navbar.js",
|
||||
"./layouts/home/not-found": "./dist/layouts/home/not-found.js",
|
||||
@@ -87,11 +94,6 @@
|
||||
"./layouts/notebook/slots/container": "./dist/layouts/notebook/slots/container.js",
|
||||
"./layouts/notebook/slots/header": "./dist/layouts/notebook/slots/header.js",
|
||||
"./layouts/notebook/slots/sidebar": "./dist/layouts/notebook/slots/sidebar.js",
|
||||
"./layouts/pointer": "./dist/layouts/pointer/index.js",
|
||||
"./layouts/pointer/page": "./dist/layouts/pointer/page/index.js",
|
||||
"./layouts/pointer/page/slots/breadcrumb": "./dist/layouts/pointer/page/slots/breadcrumb.js",
|
||||
"./layouts/pointer/page/slots/footer": "./dist/layouts/pointer/page/slots/footer.js",
|
||||
"./layouts/pointer/page/slots/toc": "./dist/layouts/pointer/page/slots/toc.js",
|
||||
"./layouts/shared": "./dist/layouts/shared/index.js",
|
||||
"./layouts/shared/slots/language-select": "./dist/layouts/shared/slots/language-select.js",
|
||||
"./layouts/shared/slots/search-trigger": "./dist/layouts/shared/slots/search-trigger.js",
|
||||
|
||||
@@ -137,6 +137,28 @@ export const registry: Registry = {
|
||||
],
|
||||
unlisted: true,
|
||||
},
|
||||
{
|
||||
name: 'layouts/glass',
|
||||
files: [
|
||||
{
|
||||
type: 'layout',
|
||||
path: 'layouts/glass/index.tsx',
|
||||
target: '<dir>/glass/index.tsx',
|
||||
meta: {
|
||||
preserveAs: 'layouts/glass',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'layout',
|
||||
path: 'layouts/glass/page/index.tsx',
|
||||
target: '<dir>/glass/page/index.tsx',
|
||||
meta: {
|
||||
preserveAs: 'layouts/glass/page',
|
||||
},
|
||||
},
|
||||
],
|
||||
unlisted: true,
|
||||
},
|
||||
{
|
||||
name: 'layouts/home',
|
||||
files: [
|
||||
@@ -302,6 +324,16 @@ export const registry: Registry = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'dropdown-menu',
|
||||
unlisted: true,
|
||||
files: [
|
||||
{
|
||||
type: 'ui',
|
||||
path: 'components/ui/dropdown-menu.tsx',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'scroll-area',
|
||||
unlisted: true,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
import type * as PageTree from 'fumadocs-core/page-tree';
|
||||
import {
|
||||
type BaseLayoutProps,
|
||||
type BaseSlots,
|
||||
type BaseSlotsProps,
|
||||
baseSlots,
|
||||
getLayoutTabs,
|
||||
type GetLayoutTabsOptions,
|
||||
type LayoutTab,
|
||||
type LinkItemType,
|
||||
useLinkItems,
|
||||
} from '@/layouts/shared';
|
||||
import { TreeContextProvider } from '@/contexts/tree';
|
||||
import { createContext, type FC, use, useMemo } from 'react';
|
||||
import { Sidebar, SidebarDrawer, type SidebarProps } from './slots/sidebar';
|
||||
import { Header, type HeaderProps } from './slots/header';
|
||||
|
||||
export interface GlassSlots extends BaseSlots {
|
||||
header: FC<HeaderProps>;
|
||||
sidebar: FC<SidebarProps>;
|
||||
sidebarDrawer: FC<SidebarProps>;
|
||||
}
|
||||
|
||||
export interface GlassLayoutProps extends BaseLayoutProps {
|
||||
tree: PageTree.Root;
|
||||
tabs?: LayoutTab[] | GetLayoutTabsOptions | false;
|
||||
aiChat?: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
};
|
||||
slots?: Partial<GlassSlots>;
|
||||
}
|
||||
|
||||
interface SlotsProps extends BaseSlotsProps<GlassLayoutProps> {
|
||||
tabs: LayoutTab[];
|
||||
aiChat?: GlassLayoutProps['aiChat'];
|
||||
}
|
||||
|
||||
const LayoutContext = createContext<{
|
||||
props: SlotsProps;
|
||||
navItems: LinkItemType[];
|
||||
menuItems: LinkItemType[];
|
||||
slots: GlassSlots;
|
||||
} | null>(null);
|
||||
|
||||
export function useGlassLayout() {
|
||||
const context = use(LayoutContext);
|
||||
if (!context)
|
||||
throw new Error(
|
||||
'Please use Glass layout components under <GlassLayout /> (`fumadocs-ui/layouts/glass`).',
|
||||
);
|
||||
return context;
|
||||
}
|
||||
|
||||
const { useProvider } = baseSlots({
|
||||
useProps() {
|
||||
return useGlassLayout().props;
|
||||
},
|
||||
});
|
||||
|
||||
export function GlassLayout(props: GlassLayoutProps) {
|
||||
const { tree, tabs: defaultTabs, aiChat, children, slots: defaultSlots = {} } = props;
|
||||
const linkItems = useLinkItems(props);
|
||||
const { baseSlots, baseProps } = useProvider(props);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
if (Array.isArray(defaultTabs)) {
|
||||
return defaultTabs;
|
||||
}
|
||||
if (typeof defaultTabs === 'object') {
|
||||
return getLayoutTabs(tree, defaultTabs);
|
||||
}
|
||||
if (defaultTabs !== false) {
|
||||
return getLayoutTabs(tree);
|
||||
}
|
||||
return [];
|
||||
}, [tree, defaultTabs]);
|
||||
|
||||
const slots: GlassSlots = {
|
||||
...baseSlots,
|
||||
header: defaultSlots.header ?? Header,
|
||||
sidebar: defaultSlots.sidebar ?? Sidebar,
|
||||
sidebarDrawer: defaultSlots.sidebarDrawer ?? SidebarDrawer,
|
||||
};
|
||||
|
||||
return (
|
||||
<LayoutContext
|
||||
value={{
|
||||
props: {
|
||||
tabs,
|
||||
aiChat,
|
||||
...baseProps,
|
||||
},
|
||||
slots,
|
||||
...linkItems,
|
||||
}}
|
||||
>
|
||||
<TreeContextProvider tree={tree}>
|
||||
<div
|
||||
id="fd-glass-layout"
|
||||
className="grid overflow-x-clip min-h-dvh [--page-col:900px] [--fd-left-width:0px] [--fd-right-width:0px] has-data-[fd-full=true]:[--page-col:1200px] md:[--fd-left-width:280px]"
|
||||
style={{
|
||||
gridTemplate: `"left left-margin main right-margin right" 1fr / var(--fd-left-width) minmax(0,1fr) minmax(0px, var(--page-col)) minmax(0,1fr) var(--fd-right-width)`,
|
||||
}}
|
||||
>
|
||||
<slots.sidebarDrawer />
|
||||
<slots.sidebar />
|
||||
<slots.header />
|
||||
{children}
|
||||
</div>
|
||||
</TreeContextProvider>
|
||||
</LayoutContext>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -30,7 +30,7 @@ export function useDocsPage() {
|
||||
const context = use(PageContext);
|
||||
if (!context)
|
||||
throw new Error(
|
||||
'Please use page components under <DocsPage /> (`fumadocs-ui/layouts/docs/page`).',
|
||||
'Please use page components under <DocsPage /> (`fumadocs-ui/layouts/glass/page`).',
|
||||
);
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
import { Drawer } from '@base-ui/react/drawer';
|
||||
import { isLayoutTabActive, type LayoutTab } from '@/layouts/shared';
|
||||
import { useTreePath } from '@/contexts/tree';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import { type ComponentProps, useState } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
LanguagesIcon,
|
||||
MessageCircleIcon,
|
||||
MoreHorizontalIcon,
|
||||
SidebarIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { useTranslations } from '@fuma-translate/react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { usePathname } from 'fumadocs-core/framework';
|
||||
import { useGlassLayout } from '..';
|
||||
import { drawerHandle } from './sidebar';
|
||||
|
||||
export type HeaderProps = ComponentProps<'div'>;
|
||||
|
||||
const baseVariants =
|
||||
'rounded-full bg-fd-popover/80 text-fd-popover-foreground border backdrop-blur-sm shadow-md';
|
||||
|
||||
export function Header({ className, ...props }: HeaderProps) {
|
||||
const {
|
||||
props: { tabs, aiChat },
|
||||
slots,
|
||||
} = useGlassLayout();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'sticky flex flex-row gap-2 [grid-area:left-margin/left-margin/right/right] z-20 px-4 md:top-0 md:pt-2 md:px-5 md:h-12 md:bg-linear-to-b md:from-fd-background max-md:bottom-0 max-md:mt-auto max-md:h-14 max-md:pb-4 max-md:bg-linear-to-t max-md:from-fd-background',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'secondary',
|
||||
size: 'icon-sm',
|
||||
}),
|
||||
baseVariants,
|
||||
'size-10 shrink-0 data-popup-open:bg-fd-accent md:hidden',
|
||||
)}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-48">
|
||||
{aiChat && (
|
||||
<DropdownMenuItem onClick={() => aiChat.onOpenChange(!aiChat.open)}>
|
||||
<MessageCircleIcon className="size-4 text-fd-muted-foreground" />
|
||||
{t('Ask AI', { note: 'AI chat button' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(slots.languageSelect || slots.themeSwitch) && (
|
||||
<div className="flex items-center justify-between border-t mt-2 pt-2 p-1 gap-2 first:border-t-0 first:mt-0">
|
||||
{slots.languageSelect && (
|
||||
<slots.languageSelect.root className="flex-1">
|
||||
<slots.languageSelect.text />
|
||||
<ChevronsUpDown className="ms-auto size-3.5 text-fd-muted-foreground shrink-0" />
|
||||
</slots.languageSelect.root>
|
||||
)}
|
||||
{slots.themeSwitch && <slots.themeSwitch className="p-0" />}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{tabs.length > 0 && (
|
||||
<LayoutTabs tabs={tabs} className={cn(baseVariants, 'min-w-0 max-sm:flex-1')} />
|
||||
)}
|
||||
{slots.languageSelect && (
|
||||
<slots.languageSelect.root className={cn(baseVariants, 'px-3 rounded-full max-md:hidden')}>
|
||||
<LanguagesIcon className="size-4 text-fd-muted-foreground shrink-0" />
|
||||
<slots.languageSelect.text />
|
||||
<ChevronsUpDown className="size-3.5 text-fd-muted-foreground shrink-0" />
|
||||
</slots.languageSelect.root>
|
||||
)}
|
||||
{slots.searchTrigger && (
|
||||
<>
|
||||
<slots.searchTrigger.sm
|
||||
color="secondary"
|
||||
className={cn(baseVariants, 'ms-auto size-10 shrink-0 lg:hidden')}
|
||||
/>
|
||||
<slots.searchTrigger.full
|
||||
className={cn(
|
||||
baseVariants,
|
||||
'ms-auto max-w-[280px] flex-1 text-fd-muted-foreground ps-3 pe-2.5 max-lg:hidden',
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{aiChat && !aiChat.open && (
|
||||
<button
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'secondary' }),
|
||||
baseVariants,
|
||||
'px-3 gap-2 text-fd-muted-foreground shrink-0 max-md:hidden',
|
||||
)}
|
||||
onClick={() => aiChat.onOpenChange(true)}
|
||||
>
|
||||
<MessageCircleIcon className="size-4" />
|
||||
{t('Ask AI', { note: 'AI chat button' })}
|
||||
</button>
|
||||
)}
|
||||
<Drawer.Trigger
|
||||
handle={drawerHandle}
|
||||
render={(props, { open }) => (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'secondary', size: 'icon-sm' }),
|
||||
baseVariants,
|
||||
'shrink-0 size-10 md:hidden',
|
||||
)}
|
||||
aria-label={
|
||||
open
|
||||
? t('Close Sidebar', { note: 'aria-label' })
|
||||
: t('Open Sidebar', { note: 'aria-label' })
|
||||
}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutTabs({
|
||||
tabs,
|
||||
className,
|
||||
...props
|
||||
}: { tabs: LayoutTab[] } & ComponentProps<typeof PopoverTrigger>) {
|
||||
const path = useTreePath();
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations();
|
||||
const selected = tabs.findLast((t) => isLayoutTabActive(t, path, pathname));
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 text-sm px-3 py-1 font-medium rounded-full transition-colors [&_svg]:size-4 hover:bg-fd-accent data-popup-open:bg-fd-accent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{selected ? (
|
||||
<>
|
||||
{selected.icon}
|
||||
<span className="truncate">{selected.title}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-fd-muted-foreground truncate">
|
||||
{t('Layout Tab', { note: 'layout tab trigger' })}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="ms-auto shrink-0 text-fd-muted-foreground size-3.5!" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col p-1 w-(--anchor-width)" align="start">
|
||||
{tabs.map((t, i) => {
|
||||
if (t.unlisted && t !== selected) return;
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
href={t.url}
|
||||
className={cn(
|
||||
'text-sm px-2 py-1.5 rounded-lg',
|
||||
selected === t
|
||||
? 'bg-fd-primary/10 text-fd-primary'
|
||||
: 'hover:bg-fd-accent hover:text-fd-accent-foreground',
|
||||
)}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className="font-medium inline-flex items-center gap-2 [&_svg]:size-4">
|
||||
{t.icon}
|
||||
{t.title}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 text-xs text-fd-muted-foreground empty:hidden',
|
||||
selected === t && 'text-fd-primary/80',
|
||||
)}
|
||||
>
|
||||
{t.description}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
+31
-276
@@ -1,58 +1,24 @@
|
||||
'use client';
|
||||
import { Drawer } from '@base-ui/react/drawer';
|
||||
import type * as PageTree from 'fumadocs-core/page-tree';
|
||||
import {
|
||||
getLayoutTabs,
|
||||
GetLayoutTabsOptions,
|
||||
GroupedLinkItems,
|
||||
IconItemType,
|
||||
isLayoutTabActive,
|
||||
isLinkItemActive,
|
||||
LinkItemType,
|
||||
useLinkItems,
|
||||
type BaseLayoutProps,
|
||||
type LayoutTab,
|
||||
} from '@/layouts/shared';
|
||||
import { TreeContextProvider, useTreeContext, useTreePath } from '@/contexts/tree';
|
||||
import { type IconItemType, isLinkItemActive, type LinkItemType } from '@/layouts/shared';
|
||||
import { useTreeContext, useTreePath } from '@/contexts/tree';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { cloneElement, ComponentProps, ReactNode, useMemo, useState } from 'react';
|
||||
import { cloneElement, type ComponentProps, useState } from 'react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
LanguagesIcon,
|
||||
MessageCircleIcon,
|
||||
MoreHorizontalIcon,
|
||||
SidebarIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import { ChevronDown, XIcon } from 'lucide-react';
|
||||
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ScrollArea, ScrollViewport } from '@/components/ui/scroll-area';
|
||||
import { FullSearchTrigger, SearchTrigger } from '../shared/slots/search-trigger';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { ThemeSwitch } from '../shared/slots/theme-switch';
|
||||
import { LanguageSelect, LanguageSelectText } from '../shared/slots/language-select';
|
||||
import { useI18n } from '@/contexts/i18n';
|
||||
import { useTranslations } from '@fuma-translate/react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { usePathname } from 'fumadocs-core/framework';
|
||||
import { useTranslations } from '@fuma-translate/react';
|
||||
import { useGlassLayout } from '..';
|
||||
|
||||
export interface PointerLayoutProps extends BaseLayoutProps {
|
||||
tree: PageTree.Root;
|
||||
tabs?: LayoutTab[] | GetLayoutTabsOptions | false;
|
||||
aiChat?: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
};
|
||||
}
|
||||
export type SidebarProps = ComponentProps<'aside'>;
|
||||
|
||||
export const drawerHandle = Drawer.createHandle();
|
||||
|
||||
const itemTriggerVariants = cva(
|
||||
'inline-flex text-sm items-center gap-2 rounded-lg px-3 py-2 md:py-1.5 [&_svg]:size-4 outline-none focus-visible:ring-2 focus-visible:ring-fd-ring',
|
||||
@@ -66,59 +32,10 @@ const itemTriggerVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
const drawerHandle = Drawer.createHandle();
|
||||
|
||||
export function PointerLayout({
|
||||
tree,
|
||||
nav,
|
||||
tabs: defaultTabs,
|
||||
links: defaultLinks,
|
||||
children,
|
||||
githubUrl,
|
||||
aiChat,
|
||||
}: PointerLayoutProps) {
|
||||
const banner =
|
||||
typeof nav?.title === 'function' ? (
|
||||
<nav.title />
|
||||
) : (
|
||||
<Link href={nav?.url ?? '/'} className="inline-flex items-center font-semibold gap-2">
|
||||
{nav?.title}
|
||||
</Link>
|
||||
);
|
||||
const linkItems = useLinkItems({ links: defaultLinks, githubUrl });
|
||||
const tabs = useMemo(() => {
|
||||
if (Array.isArray(defaultTabs)) {
|
||||
return defaultTabs;
|
||||
}
|
||||
if (typeof defaultTabs === 'object') {
|
||||
return getLayoutTabs(tree, defaultTabs);
|
||||
}
|
||||
if (defaultTabs !== false) {
|
||||
return getLayoutTabs(tree);
|
||||
}
|
||||
return [];
|
||||
}, [tree, defaultTabs]);
|
||||
|
||||
return (
|
||||
<TreeContextProvider tree={tree}>
|
||||
<div
|
||||
id="fd-pointer-layout"
|
||||
className="grid overflow-x-clip min-h-dvh [--page-col:900px] [--fd-left-width:0px] [--fd-right-width:0px] has-data-[fd-full=true]:[--page-col:1200px] md:[--fd-left-width:280px]"
|
||||
style={{
|
||||
gridTemplate: `"left left-margin main right-margin right" 1fr / var(--fd-left-width) minmax(0,1fr) minmax(0px, var(--page-col)) minmax(0,1fr) var(--fd-right-width)`,
|
||||
}}
|
||||
>
|
||||
<SidebarDrawer banner={banner} links={linkItems} />
|
||||
<Sidebar banner={banner} links={linkItems} />
|
||||
<Header tabs={tabs || undefined} aiChat={aiChat} />
|
||||
{children}
|
||||
</div>
|
||||
</TreeContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarDrawer({ banner, links }: { banner?: ReactNode; links: GroupedLinkItems }) {
|
||||
export function SidebarDrawer() {
|
||||
const { menuItems, slots } = useGlassLayout();
|
||||
const { root } = useTreeContext();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Drawer.Root handle={drawerHandle} swipeDirection="right">
|
||||
@@ -131,9 +48,12 @@ function SidebarDrawer({ banner, links }: { banner?: ReactNode; links: GroupedLi
|
||||
>
|
||||
<Drawer.Content className="flex flex-col px-3 size-full overflow-y-auto overscroll-contain fd-scroll-container">
|
||||
<div className="sticky flex items-center top-0 py-2 bg-fd-background shadow-lg shadow-fd-background">
|
||||
<Drawer.Title className="px-3 flex-1">{banner}</Drawer.Title>
|
||||
<Drawer.Title className="px-3 flex-1">
|
||||
<slots.navTitle className="inline-flex items-center font-semibold gap-2" />
|
||||
</Drawer.Title>
|
||||
|
||||
<Drawer.Close
|
||||
aria-label={t('Close Sidebar', { note: 'aria-label' })}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'secondary', size: 'icon-sm' }),
|
||||
'rounded-full',
|
||||
@@ -143,13 +63,13 @@ function SidebarDrawer({ banner, links }: { banner?: ReactNode; links: GroupedLi
|
||||
</Drawer.Close>
|
||||
</div>
|
||||
<div className="flex flex-col py-2 flex-1">
|
||||
{links.all.map(
|
||||
{menuItems.map(
|
||||
(item, i) => item.type !== 'icon' && <SidebarLinkItem key={i} item={item} />,
|
||||
)}
|
||||
{root.children.map((item, i) => cloneElement(renderNode(item), { key: i }))}
|
||||
</div>
|
||||
<div className="flex items-center sticky bottom-0 bg-fd-background px-1.5 pt-2 pb-4 border-t mt-2 empty:hidden">
|
||||
{links.all.map(
|
||||
{menuItems.map(
|
||||
(item, i) => item.type === 'icon' && <SidebarIconLinkItem key={i} item={item} />,
|
||||
)}
|
||||
</div>
|
||||
@@ -161,40 +81,37 @@ function SidebarDrawer({ banner, links }: { banner?: ReactNode; links: GroupedLi
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
banner,
|
||||
links,
|
||||
footer,
|
||||
}: {
|
||||
banner?: ReactNode;
|
||||
links: GroupedLinkItems;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
export function Sidebar({ className, children, ...props }: SidebarProps) {
|
||||
const { menuItems, slots } = useGlassLayout();
|
||||
const { root } = useTreeContext();
|
||||
|
||||
return (
|
||||
<aside
|
||||
id="nd-sidebar"
|
||||
className="sticky flex flex-col [grid-area:left] my-2 ms-2 z-20 top-2 border rounded-2xl bg-fd-popover/80 text-fd-popover-foreground shadow-md h-[calc(100dvh---spacing(4))] max-md:hidden"
|
||||
className={cn(
|
||||
'sticky flex flex-col [grid-area:left] my-2 ms-2 z-20 top-2 border rounded-2xl bg-fd-popover/80 text-fd-popover-foreground shadow-md h-[calc(100dvh---spacing(4))] max-md:hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center text-sm gap-2 ps-4 p-2 h-10 border-b border-fd-foreground/10">
|
||||
{banner}
|
||||
<ThemeSwitch className="ms-auto shrink-0 p-0 max-md:hidden" />
|
||||
<slots.navTitle className="inline-flex items-center font-semibold gap-2" />
|
||||
{slots.themeSwitch && <slots.themeSwitch className="ms-auto shrink-0 p-0 max-md:hidden" />}
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<ScrollViewport className="flex flex-col p-2 [mask-image:linear-gradient(to_bottom,transparent,white_16px,white_calc(100%-16px),transparent))]">
|
||||
{links.all.map(
|
||||
{menuItems.map(
|
||||
(item, i) => item.type !== 'icon' && <SidebarLinkItem key={i} item={item} />,
|
||||
)}
|
||||
{root.children.map((item, i) => cloneElement(renderNode(item), { key: i }))}
|
||||
</ScrollViewport>
|
||||
</ScrollArea>
|
||||
<div className="flex items-center px-3.5 py-2 border-t mt-2 empty:hidden">
|
||||
{links.all.map(
|
||||
{menuItems.map(
|
||||
(item, i) => item.type === 'icon' && <SidebarIconLinkItem key={i} item={item} />,
|
||||
)}
|
||||
</div>
|
||||
{footer}
|
||||
{children}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -204,6 +121,7 @@ function SidebarIconLinkItem({ item, className }: { item: IconItemType; classNam
|
||||
<Link
|
||||
href={item.url}
|
||||
external={item.external}
|
||||
aria-label={item.label}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
|
||||
'text-fd-muted-foreground',
|
||||
@@ -305,169 +223,6 @@ function SidebarLinkItem({
|
||||
}
|
||||
}
|
||||
|
||||
function Header({ tabs, aiChat }: { tabs?: LayoutTab[] } & Pick<PointerLayoutProps, 'aiChat'>) {
|
||||
const { locales } = useI18n();
|
||||
const t = useTranslations();
|
||||
const baseVariants =
|
||||
'rounded-full bg-fd-popover/80 text-fd-popover-foreground border backdrop-blur-sm shadow-md';
|
||||
|
||||
return (
|
||||
<div className="sticky flex flex-row gap-2 [grid-area:left-margin/left-margin/right/right] z-20 px-4 md:top-0 md:pt-2 md:px-5 md:h-12 md:bg-linear-to-b md:from-fd-background max-md:bottom-0 max-md:mt-auto max-md:h-14 max-md:pb-4 max-md:bg-linear-to-t max-md:from-fd-background">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'secondary',
|
||||
size: 'icon-sm',
|
||||
}),
|
||||
baseVariants,
|
||||
'size-10 shrink-0 data-popup-open:bg-fd-accent md:hidden',
|
||||
)}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-48">
|
||||
{aiChat && (
|
||||
<DropdownMenuItem onClick={() => aiChat.onOpenChange(!aiChat.open)}>
|
||||
<MessageCircleIcon className="size-4 text-fd-muted-foreground" />
|
||||
Ask AI
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<div className="flex items-center justify-between border-t mt-2 pt-2 p-1 gap-2 first:border-t-0 first:mt-0">
|
||||
{locales && locales.length > 0 && (
|
||||
<LanguageSelect className="flex-1">
|
||||
<LanguageSelectText />
|
||||
<ChevronsUpDown className="ms-auto size-3.5 text-fd-muted-foreground shrink-0" />
|
||||
</LanguageSelect>
|
||||
)}
|
||||
<ThemeSwitch className="p-0" />
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{tabs && tabs.length > 0 && (
|
||||
<LayoutTabs tabs={tabs} className={cn(baseVariants, 'min-w-0 max-sm:flex-1')} />
|
||||
)}
|
||||
{locales && locales.length > 0 && (
|
||||
<LanguageSelect className={cn(baseVariants, 'px-3 rounded-full max-md:hidden')}>
|
||||
<LanguagesIcon className="size-4 text-fd-muted-foreground shrink-0" />
|
||||
<LanguageSelectText />
|
||||
<ChevronsUpDown className="size-3.5 text-fd-muted-foreground shrink-0" />
|
||||
</LanguageSelect>
|
||||
)}
|
||||
<SearchTrigger
|
||||
color="secondary"
|
||||
className={cn(baseVariants, 'ms-auto size-10 shrink-0 lg:hidden')}
|
||||
/>
|
||||
<FullSearchTrigger
|
||||
className={cn(
|
||||
baseVariants,
|
||||
'ms-auto max-w-[280px] flex-1 text-fd-muted-foreground ps-3 pe-2.5 max-lg:hidden',
|
||||
)}
|
||||
/>
|
||||
{aiChat && !aiChat.open && (
|
||||
<button
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'secondary' }),
|
||||
baseVariants,
|
||||
'px-3 gap-2 text-fd-muted-foreground shrink-0 max-md:hidden',
|
||||
)}
|
||||
onClick={() => aiChat.onOpenChange(true)}
|
||||
>
|
||||
<MessageCircleIcon className="size-4" />
|
||||
Ask AI
|
||||
</button>
|
||||
)}
|
||||
<Drawer.Trigger
|
||||
handle={drawerHandle}
|
||||
render={(props, { open }) => (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'secondary', size: 'icon-sm' }),
|
||||
baseVariants,
|
||||
'shrink-0 size-10 md:hidden',
|
||||
)}
|
||||
aria-label={
|
||||
open
|
||||
? t('Close Sidebar', { note: 'aria-label' })
|
||||
: t('Open Sidebar', { note: 'aria-label' })
|
||||
}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutTabs({
|
||||
tabs,
|
||||
className,
|
||||
...props
|
||||
}: { tabs: LayoutTab[] } & ComponentProps<typeof PopoverTrigger>) {
|
||||
const path = useTreePath();
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations();
|
||||
const selected = tabs.findLast((t) => isLayoutTabActive(t, path, pathname));
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 text-sm px-3 py-1 font-medium rounded-full transition-colors [&_svg]:size-4 hover:bg-fd-accent data-popup-open:bg-fd-accent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{selected ? (
|
||||
<>
|
||||
{selected.icon}
|
||||
<span className="truncate">{selected.title}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-fd-muted-foreground truncate">
|
||||
{t('Layout Tab', { note: 'layout tab trigger' })}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="ms-auto shrink-0 text-fd-muted-foreground size-3.5!" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col p-1 w-(--anchor-width)" align="start">
|
||||
{tabs.map((t, i) => {
|
||||
if (t.unlisted && t !== selected) return;
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
href={t.url}
|
||||
className={cn(
|
||||
'text-sm px-2 py-1.5 rounded-lg',
|
||||
selected === t
|
||||
? 'bg-fd-primary/10 text-fd-primary'
|
||||
: 'hover:bg-fd-accent hover:text-fd-accent-foreground',
|
||||
)}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className="font-medium inline-flex items-center gap-2 [&_svg]:size-4">
|
||||
{t.icon}
|
||||
{t.title}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 text-xs text-fd-muted-foreground empty:hidden',
|
||||
selected === t && 'text-fd-primary/80',
|
||||
)}
|
||||
>
|
||||
{t.description}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function renderNode(node: PageTree.Node) {
|
||||
if (node.type === 'page') return <SidebarItem item={node} />;
|
||||
if (node.type === 'folder') return <SidebarFolder folder={node} />;
|
||||
@@ -72,7 +72,7 @@ export async function compileInline() {
|
||||
const commonNames = scanner.scan();
|
||||
await writeFile('css/generated/shared.css', namesToFile(commonNames));
|
||||
|
||||
const layouts = ['flux', 'notebook', 'home', 'docs', 'pointer'];
|
||||
const layouts = ['flux', 'notebook', 'home', 'docs', 'glass'];
|
||||
const commonNameSet = new Set(commonNames);
|
||||
|
||||
for (const layout of layouts) {
|
||||
|
||||
@@ -27,7 +27,6 @@ import { registerDefault } from '@/requests/generators/all';
|
||||
import { createCodeUsageGeneratorRegistry } from '@/requests/generators';
|
||||
import type { ShikiFactory } from 'fumadocs-core/highlight/shiki';
|
||||
import type { GeneratedPageProps } from '@/utils/pages/builder';
|
||||
import type { ParsedSchema } from '@/utils/schema';
|
||||
import { Markdown } from './components/markdown';
|
||||
import { Schema, type SchemaUIOptions } from '@fumadocs/api-docs/components/schema';
|
||||
import { RenderContextProvider } from './contexts/api';
|
||||
|
||||
@@ -279,7 +279,8 @@
|
||||
#nd-docs-layout:has(&),
|
||||
#nd-notebook-layout:has(&),
|
||||
#nd-home-layout:has(&),
|
||||
#nd-flux-layout:has(&) {
|
||||
#nd-flux-layout:has(&),
|
||||
#fd-glass-layout:has(&) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
@import './generated/home.css';
|
||||
@import './generated/notebook.css';
|
||||
@import './generated/flux.css';
|
||||
@import './generated/glass.css';
|
||||
|
||||
@import './generated/shared.css';
|
||||
|
||||
@@ -76,6 +76,13 @@
|
||||
"./layouts/flux/slots/container": "./dist/layouts/flux/slots/container.js",
|
||||
"./layouts/flux/slots/sidebar": "./dist/layouts/flux/slots/sidebar.js",
|
||||
"./layouts/flux/slots/tab-dropdown": "./dist/layouts/flux/slots/tab-dropdown.js",
|
||||
"./layouts/glass": "./dist/layouts/glass/index.js",
|
||||
"./layouts/glass/page": "./dist/layouts/glass/page/index.js",
|
||||
"./layouts/glass/page/slots/breadcrumb": "./dist/layouts/glass/page/slots/breadcrumb.js",
|
||||
"./layouts/glass/page/slots/footer": "./dist/layouts/glass/page/slots/footer.js",
|
||||
"./layouts/glass/page/slots/toc": "./dist/layouts/glass/page/slots/toc.js",
|
||||
"./layouts/glass/slots/header": "./dist/layouts/glass/slots/header.js",
|
||||
"./layouts/glass/slots/sidebar": "./dist/layouts/glass/slots/sidebar.js",
|
||||
"./layouts/home": "./dist/layouts/home/index.js",
|
||||
"./layouts/home/navbar": "./dist/layouts/home/navbar.js",
|
||||
"./layouts/home/not-found": "./dist/layouts/home/not-found.js",
|
||||
|
||||
@@ -137,6 +137,28 @@ export const registry: Registry = {
|
||||
],
|
||||
unlisted: true,
|
||||
},
|
||||
{
|
||||
name: 'layouts/glass',
|
||||
files: [
|
||||
{
|
||||
type: 'layout',
|
||||
path: 'layouts/glass/index.tsx',
|
||||
target: '<dir>/glass/index.tsx',
|
||||
meta: {
|
||||
preserveAs: 'layouts/glass',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'layout',
|
||||
path: 'layouts/glass/page/index.tsx',
|
||||
target: '<dir>/glass/page/index.tsx',
|
||||
meta: {
|
||||
preserveAs: 'layouts/glass/page',
|
||||
},
|
||||
},
|
||||
],
|
||||
unlisted: true,
|
||||
},
|
||||
{
|
||||
name: 'layouts/home',
|
||||
files: [
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
import type * as PageTree from 'fumadocs-core/page-tree';
|
||||
import {
|
||||
type BaseLayoutProps,
|
||||
type BaseSlots,
|
||||
type BaseSlotsProps,
|
||||
baseSlots,
|
||||
getLayoutTabs,
|
||||
type GetLayoutTabsOptions,
|
||||
type LayoutTab,
|
||||
type LinkItemType,
|
||||
useLinkItems,
|
||||
} from '@/layouts/shared';
|
||||
import { TreeContextProvider } from '@/contexts/tree';
|
||||
import { createContext, type FC, use, useMemo } from 'react';
|
||||
import { SidebarProvider } from '@/components/sidebar/base';
|
||||
import { Sidebar, SidebarDrawer, type SidebarProps } from './slots/sidebar';
|
||||
import { Header, type HeaderProps } from './slots/header';
|
||||
|
||||
export interface GlassSlots extends BaseSlots {
|
||||
header: FC<HeaderProps>;
|
||||
sidebar: FC<SidebarProps>;
|
||||
sidebarDrawer: FC<SidebarProps>;
|
||||
}
|
||||
|
||||
export interface GlassLayoutProps extends BaseLayoutProps {
|
||||
tree: PageTree.Root;
|
||||
tabs?: LayoutTab[] | GetLayoutTabsOptions | false;
|
||||
aiChat?: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
};
|
||||
slots?: Partial<GlassSlots>;
|
||||
}
|
||||
|
||||
interface SlotsProps extends BaseSlotsProps<GlassLayoutProps> {
|
||||
tabs: LayoutTab[];
|
||||
aiChat?: GlassLayoutProps['aiChat'];
|
||||
}
|
||||
|
||||
const LayoutContext = createContext<{
|
||||
props: SlotsProps;
|
||||
navItems: LinkItemType[];
|
||||
menuItems: LinkItemType[];
|
||||
slots: GlassSlots;
|
||||
} | null>(null);
|
||||
|
||||
export function useGlassLayout() {
|
||||
const context = use(LayoutContext);
|
||||
if (!context)
|
||||
throw new Error(
|
||||
'Please use Glass layout components under <GlassLayout /> (`fumadocs-ui/layouts/glass`).',
|
||||
);
|
||||
return context;
|
||||
}
|
||||
|
||||
const { useProvider } = baseSlots({
|
||||
useProps() {
|
||||
return useGlassLayout().props;
|
||||
},
|
||||
});
|
||||
|
||||
export function GlassLayout(props: GlassLayoutProps) {
|
||||
const { tree, tabs: defaultTabs, aiChat, children, slots: defaultSlots = {} } = props;
|
||||
const linkItems = useLinkItems(props);
|
||||
const { baseSlots, baseProps } = useProvider(props);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
if (Array.isArray(defaultTabs)) {
|
||||
return defaultTabs;
|
||||
}
|
||||
if (typeof defaultTabs === 'object') {
|
||||
return getLayoutTabs(tree, defaultTabs);
|
||||
}
|
||||
if (defaultTabs !== false) {
|
||||
return getLayoutTabs(tree);
|
||||
}
|
||||
return [];
|
||||
}, [tree, defaultTabs]);
|
||||
|
||||
const slots: GlassSlots = {
|
||||
...baseSlots,
|
||||
header: defaultSlots.header ?? Header,
|
||||
sidebar: defaultSlots.sidebar ?? Sidebar,
|
||||
sidebarDrawer: defaultSlots.sidebarDrawer ?? SidebarDrawer,
|
||||
};
|
||||
|
||||
return (
|
||||
<LayoutContext
|
||||
value={{
|
||||
props: {
|
||||
tabs,
|
||||
aiChat,
|
||||
...baseProps,
|
||||
},
|
||||
slots,
|
||||
...linkItems,
|
||||
}}
|
||||
>
|
||||
<TreeContextProvider tree={tree}>
|
||||
<SidebarProvider>
|
||||
<div
|
||||
id="fd-glass-layout"
|
||||
className="grid overflow-x-clip min-h-dvh [--page-col:900px] [--fd-left-width:0px] [--fd-right-width:0px] has-data-[fd-full=true]:[--page-col:1200px] md:[--fd-left-width:280px]"
|
||||
style={{
|
||||
gridTemplate: `"left left-margin main right-margin right" 1fr / var(--fd-left-width) minmax(0,1fr) minmax(0px, var(--page-col)) minmax(0,1fr) var(--fd-right-width)`,
|
||||
}}
|
||||
>
|
||||
<slots.sidebarDrawer />
|
||||
<slots.sidebar />
|
||||
<slots.header />
|
||||
{children}
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</TreeContextProvider>
|
||||
</LayoutContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
import { type ComponentProps, createContext, use, useEffect, useState } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useTranslations } from '@fuma-translate/react';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { Edit } from 'lucide-react';
|
||||
import { Footer } from './slots/footer';
|
||||
import { Breadcrumb } from './slots/breadcrumb';
|
||||
import { TOC, type TOCProps, TOCProvider } from './slots/toc';
|
||||
import { TOCItemType } from 'fumadocs-core/toc';
|
||||
|
||||
export interface DocsPageProps extends ComponentProps<'article'> {
|
||||
toc?: TOCItemType[];
|
||||
|
||||
/**
|
||||
* Extend the page to fill all available space
|
||||
*
|
||||
* @defaultValue false
|
||||
*/
|
||||
full?: boolean | undefined;
|
||||
|
||||
tableOfContent?: TOCProps;
|
||||
}
|
||||
|
||||
const PageContext = createContext<{
|
||||
full: boolean;
|
||||
} | null>(null);
|
||||
|
||||
export function useDocsPage() {
|
||||
const context = use(PageContext);
|
||||
if (!context)
|
||||
throw new Error(
|
||||
'Please use page components under <DocsPage /> (`fumadocs-ui/layouts/glass/page`).',
|
||||
);
|
||||
return context;
|
||||
}
|
||||
|
||||
const Empty: never[] = [];
|
||||
|
||||
export function DocsPage({ full = false, toc = Empty, tableOfContent, children }: DocsPageProps) {
|
||||
return (
|
||||
<PageContext
|
||||
value={{
|
||||
full,
|
||||
}}
|
||||
>
|
||||
<TOCProvider toc={toc}>
|
||||
<div
|
||||
data-fd-full={full}
|
||||
className="flex flex-col gap-2 p-6 pb-16 min-w-0 [grid-area:main] md:px-8 md:pt-16 md:pb-8"
|
||||
>
|
||||
<Breadcrumb />
|
||||
{children}
|
||||
<Footer />
|
||||
</div>
|
||||
<TOC {...tableOfContent} />
|
||||
</TOCProvider>
|
||||
</PageContext>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditOnGitHub(props: ComponentProps<'a'>) {
|
||||
const t = useTranslations({ note: 'edit page' });
|
||||
|
||||
return (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
{...props}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
size: 'sm',
|
||||
}),
|
||||
'gap-1.5 not-prose',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children ?? (
|
||||
<>
|
||||
<Edit className="size-3.5" />
|
||||
{t('Edit on GitHub')}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageLastUpdate({
|
||||
date: value,
|
||||
...props
|
||||
}: Omit<ComponentProps<'p'>, 'children'> & { date: Date }) {
|
||||
const t = useTranslations({ note: 'page footer' });
|
||||
const [date, setDate] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// to the timezone of client
|
||||
setDate(value.toLocaleDateString());
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<p {...props} className={cn('text-sm text-fd-muted-foreground', props.className)}>
|
||||
{t('Last updated on')} {date}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export { MarkdownCopyButton, ViewOptionsPopover } from '@/layouts/shared/page-actions';
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useTreeContext, useTreePath } from '@/contexts/tree';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { type BreadcrumbOptions, getBreadcrumbItemsFromPath } from 'fumadocs-core/breadcrumb';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import { type ComponentProps, useMemo, Fragment } from 'react';
|
||||
|
||||
export type BreadcrumbProps = BreadcrumbOptions & ComponentProps<'div'>;
|
||||
|
||||
export function Breadcrumb({
|
||||
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,84 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from '@fuma-translate/react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { isActive } from '@/utils/urls';
|
||||
import { useFooterItems } from '@/utils/use-footer-items';
|
||||
import { usePathname } from 'fumadocs-core/framework';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import type * as PageTree from 'fumadocs-core/page-tree';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { type ComponentProps, useMemo } from 'react';
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
export function Footer({ items, children, className, ...props }: FooterProps) {
|
||||
const footerList = useFooterItems();
|
||||
const pathname = usePathname();
|
||||
const { previous, next } = useMemo(() => {
|
||||
if (items) return items;
|
||||
|
||||
const idx = footerList.findIndex((item) => isActive(item.url, pathname));
|
||||
|
||||
if (idx === -1) return {};
|
||||
return {
|
||||
previous: footerList[idx - 1],
|
||||
next: footerList[idx + 1],
|
||||
};
|
||||
}, [footerList, items, pathname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'@container grid gap-4',
|
||||
previous && next ? 'grid-cols-2' : 'grid-cols-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{previous && <FooterItem item={previous} index={0} />}
|
||||
{next && <FooterItem item={next} index={1} />}
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterItem({ item, index }: { item: Item; index: 0 | 1 }) {
|
||||
const t = useTranslations({ note: 'pagination' });
|
||||
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 ? t('Previous Page') : t('Next Page'))}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
import * as Base from '@/components/toc';
|
||||
import { useTranslations } from '@fuma-translate/react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Text } from 'lucide-react';
|
||||
import {
|
||||
createContext,
|
||||
use,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { TOCScrollArea, useTOCItems } from '@/components/toc';
|
||||
import { TOCItem } from 'fumadocs-core/toc';
|
||||
|
||||
export type TOCProviderProps = Base.TOCProviderProps;
|
||||
|
||||
const Context = createContext<{
|
||||
open: boolean;
|
||||
setMobileOpen: (v: boolean) => void;
|
||||
} | null>(null);
|
||||
|
||||
export function TOCProvider(props: TOCProviderProps) {
|
||||
return <Base.TOCProvider {...props} />;
|
||||
}
|
||||
|
||||
export interface TOCProps {
|
||||
container?: ComponentProps<'div'>;
|
||||
/**
|
||||
* Custom content in TOC container, before the main TOC
|
||||
*/
|
||||
header?: ReactNode;
|
||||
|
||||
/**
|
||||
* Custom content in TOC container, after the main TOC
|
||||
*/
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
function useContext() {
|
||||
return use(Context)!;
|
||||
}
|
||||
|
||||
export function TOC({ container, header, footer }: TOCProps) {
|
||||
const t = useTranslations({ note: 'table of contents' });
|
||||
const items = Base.useTOCItems();
|
||||
const [hover, setHover] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const exitTimerRef = useRef<number>(null);
|
||||
const open = hover || mobileOpen;
|
||||
|
||||
const ctx = useMemo(
|
||||
() => ({
|
||||
open,
|
||||
setMobileOpen,
|
||||
}),
|
||||
[open],
|
||||
);
|
||||
|
||||
if (items.length === 0 && !footer && !header) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Context value={ctx}>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-10 backdrop-blur-sm transition-opacity duration-300 [mask-image:radial-gradient(circle_at_center_right,white,white_200px,transparent_500px)] xl:hidden',
|
||||
!open && 'opacity-0 pointer-events-none',
|
||||
)}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
<TOCScrollArea
|
||||
id="nd-toc"
|
||||
{...container}
|
||||
className={cn(
|
||||
'z-10 flex flex-col transition-[width,padding] duration-300 [grid-area:right] layout:[--fd-right-width:0px] max-xl:fixed max-xl:top-1/2 max-xl:-translate-y-1/2 max-xl:end-1 max-xl:bg-fd-popover max-xl:text-fd-popover-foreground max-xl:border max-xl:rounded-xl max-xl:shadow-lg max-xl:mask-none xl:sticky xl:top-10 xl:h-[calc(100dvh---spacing(10))] xl:layout:[--fd-right-width:240px]',
|
||||
open
|
||||
? 'max-xl:w-[240px] max-xl:p-3'
|
||||
: 'max-md:w-4 max-md:ps-[calc(--spacing(1.5)-1px)] max-xl:w-6 max-xl:ps-[calc(--spacing(2.5)-1px)]',
|
||||
container?.className,
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
if (!mobileOpen && (e.pointerType === 'touch' || e.pointerType === 'pen')) {
|
||||
setMobileOpen(true);
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onPointerEnter={(e) => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
if (exitTimerRef.current) window.clearTimeout(exitTimerRef.current);
|
||||
setHover(true);
|
||||
}
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
if (exitTimerRef.current) window.clearTimeout(exitTimerRef.current);
|
||||
exitTimerRef.current = window.setTimeout(
|
||||
() => {
|
||||
setHover(false);
|
||||
},
|
||||
// If the pointer leaves the window, use a longer timeout
|
||||
window.innerWidth - e.clientX < 30 ? 700 : 50,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col my-auto max-xl:min-w-[calc(240px---spacing(6))] xl:items-end xl:text-end xl:pe-4">
|
||||
{header}
|
||||
<h3
|
||||
id="nd-toc-title"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 overflow-hidden text-xs text-fd-muted-foreground transition-[opacity,height] opacity-0 h-0',
|
||||
open
|
||||
? 'opacity-100 h-6'
|
||||
: 'xl:[@media(hover:none)]:opacity-100 xl:[@media(hover:none)]:h-6',
|
||||
)}
|
||||
>
|
||||
<Text className="size-3.5 shrink-0" />
|
||||
{t('On this page')}
|
||||
</h3>
|
||||
<TOCPanel />
|
||||
{footer}
|
||||
</div>
|
||||
</TOCScrollArea>
|
||||
</Context>
|
||||
);
|
||||
}
|
||||
|
||||
function TOCPanel({ className, ...props }: ComponentProps<'div'>) {
|
||||
const items = useTOCItems();
|
||||
const { open, setMobileOpen } = useContext();
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)} {...props}>
|
||||
{items.map((item) => (
|
||||
<TOCItem
|
||||
key={item.url}
|
||||
href={item.url}
|
||||
className={cn(
|
||||
'group h-6 prose prose-sm inline-flex items-center gap-2 text-xs text-fd-muted-foreground transition-[color,height] data-[active=true]:text-fd-primary data-[active=false]:hover:text-fd-accent-foreground xl:flex-row-reverse',
|
||||
!open && 'h-3 xl:[@media(hover:none)]:h-6',
|
||||
)}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
autoScroll={open}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 bg-fd-muted-foreground/50 rounded-full size-1 transition-[background-color,width,height] group-data-[active=true]:bg-fd-primary',
|
||||
open
|
||||
? 'w-(--width) h-px'
|
||||
: 'xl:[@media(hover:none)]:w-(--width) xl:[@media(hover:none)]:h-px',
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--width': `calc(pow(${item.depth}, 1.5) * var(--spacing))`,
|
||||
} as object
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate transition-opacity opacity-0',
|
||||
open ? 'opacity-100' : 'xl:[@media(hover:none)]:opacity-100',
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
</TOCItem>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
import { isLayoutTabActive, type LayoutTab } from '@/layouts/shared';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import { type ComponentProps, useState } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
LanguagesIcon,
|
||||
MessageCircleIcon,
|
||||
MoreHorizontalIcon,
|
||||
SidebarIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { useTranslations } from '@fuma-translate/react';
|
||||
import { usePathname } from 'fumadocs-core/framework';
|
||||
import { SidebarTrigger } from '@/components/sidebar/base';
|
||||
import { useGlassLayout } from '..';
|
||||
|
||||
export type HeaderProps = ComponentProps<'div'>;
|
||||
|
||||
const baseVariants =
|
||||
'rounded-full bg-fd-popover/80 text-fd-popover-foreground border backdrop-blur-sm shadow-md';
|
||||
|
||||
export function Header({ className, ...props }: HeaderProps) {
|
||||
const {
|
||||
props: { tabs, aiChat },
|
||||
slots,
|
||||
} = useGlassLayout();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'sticky flex flex-row gap-2 [grid-area:left-margin/left-margin/right/right] z-20 px-4 md:top-0 md:pt-2 md:px-5 md:h-12 md:bg-linear-to-b md:from-fd-background max-md:bottom-0 max-md:mt-auto max-md:h-14 max-md:pb-4 max-md:bg-linear-to-t max-md:from-fd-background',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'secondary',
|
||||
size: 'icon-sm',
|
||||
}),
|
||||
baseVariants,
|
||||
'size-10 shrink-0 data-[state=open]:bg-fd-accent md:hidden',
|
||||
)}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="flex flex-col min-w-48 p-1">
|
||||
{aiChat && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => aiChat.onOpenChange(!aiChat.open)}
|
||||
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-medium hover:bg-fd-accent hover:text-fd-accent-foreground"
|
||||
>
|
||||
<MessageCircleIcon className="size-4 text-fd-muted-foreground" />
|
||||
{t('Ask AI', { note: 'AI chat button' })}
|
||||
</button>
|
||||
)}
|
||||
{(slots.languageSelect || slots.themeSwitch) && (
|
||||
<div className="flex items-center justify-between border-t mt-2 pt-2 p-1 gap-2 first:border-t-0 first:mt-0">
|
||||
{slots.languageSelect && (
|
||||
<slots.languageSelect.root className="flex-1">
|
||||
<slots.languageSelect.text />
|
||||
<ChevronsUpDown className="ms-auto size-3.5 text-fd-muted-foreground shrink-0" />
|
||||
</slots.languageSelect.root>
|
||||
)}
|
||||
{slots.themeSwitch && <slots.themeSwitch className="p-0" />}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{tabs.length > 0 && (
|
||||
<LayoutTabs tabs={tabs} className={cn(baseVariants, 'min-w-0 max-sm:flex-1')} />
|
||||
)}
|
||||
{slots.languageSelect && (
|
||||
<slots.languageSelect.root className={cn(baseVariants, 'px-3 rounded-full max-md:hidden')}>
|
||||
<LanguagesIcon className="size-4 text-fd-muted-foreground shrink-0" />
|
||||
<slots.languageSelect.text />
|
||||
<ChevronsUpDown className="size-3.5 text-fd-muted-foreground shrink-0" />
|
||||
</slots.languageSelect.root>
|
||||
)}
|
||||
{slots.searchTrigger && (
|
||||
<>
|
||||
<slots.searchTrigger.sm
|
||||
color="secondary"
|
||||
className={cn(baseVariants, 'ms-auto size-10 shrink-0 lg:hidden')}
|
||||
/>
|
||||
<slots.searchTrigger.full
|
||||
className={cn(
|
||||
baseVariants,
|
||||
'ms-auto max-w-[280px] flex-1 text-fd-muted-foreground ps-3 pe-2.5 max-lg:hidden',
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{aiChat && !aiChat.open && (
|
||||
<button
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'secondary' }),
|
||||
baseVariants,
|
||||
'px-3 gap-2 text-fd-muted-foreground shrink-0 max-md:hidden',
|
||||
)}
|
||||
onClick={() => aiChat.onOpenChange(true)}
|
||||
>
|
||||
<MessageCircleIcon className="size-4" />
|
||||
{t('Ask AI', { note: 'AI chat button' })}
|
||||
</button>
|
||||
)}
|
||||
<SidebarTrigger
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'secondary', size: 'icon-sm' }),
|
||||
baseVariants,
|
||||
'shrink-0 size-10 md:hidden',
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarTrigger>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutTabs({
|
||||
tabs,
|
||||
className,
|
||||
...props
|
||||
}: { tabs: LayoutTab[] } & ComponentProps<typeof PopoverTrigger>) {
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations();
|
||||
const selected = tabs.findLast((t) => isLayoutTabActive(t, pathname));
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 text-sm px-3 py-1 font-medium rounded-full transition-colors [&_svg]:size-4 hover:bg-fd-accent data-[state=open]:bg-fd-accent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{selected ? (
|
||||
<>
|
||||
{selected.icon}
|
||||
<span className="truncate">{selected.title}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-fd-muted-foreground truncate">
|
||||
{t('Layout Tab', { note: 'layout tab trigger' })}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="ms-auto shrink-0 text-fd-muted-foreground size-3.5!" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col p-1 w-(--radix-popover-trigger-width)" align="start">
|
||||
{tabs.map((t, i) => {
|
||||
if (t.unlisted && t !== selected) return;
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
href={t.url}
|
||||
className={cn(
|
||||
'text-sm px-2 py-1.5 rounded-lg',
|
||||
selected === t
|
||||
? 'bg-fd-primary/10 text-fd-primary'
|
||||
: 'hover:bg-fd-accent hover:text-fd-accent-foreground',
|
||||
)}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className="font-medium inline-flex items-center gap-2 [&_svg]:size-4">
|
||||
{t.icon}
|
||||
{t.title}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 text-xs text-fd-muted-foreground empty:hidden',
|
||||
selected === t && 'text-fd-primary/80',
|
||||
)}
|
||||
>
|
||||
{t.description}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
'use client';
|
||||
import type * as PageTree from 'fumadocs-core/page-tree';
|
||||
import { type IconItemType, isLinkItemActive, type LinkItemType } from '@/layouts/shared';
|
||||
import { useTreeContext, useTreePath } from '@/contexts/tree';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { cloneElement, type ComponentProps, useState } from 'react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ChevronDown, XIcon } from 'lucide-react';
|
||||
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ScrollArea, ScrollViewport } from '@/components/ui/scroll-area';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { usePathname } from 'fumadocs-core/framework';
|
||||
import { useTranslations } from '@fuma-translate/react';
|
||||
import { SidebarDrawerContent, SidebarDrawerOverlay, useSidebar } from '@/components/sidebar/base';
|
||||
import { useGlassLayout } from '..';
|
||||
|
||||
export type SidebarProps = ComponentProps<'aside'>;
|
||||
|
||||
const itemTriggerVariants = cva(
|
||||
'inline-flex text-sm items-center gap-2 rounded-lg px-3 py-2 md:py-1.5 [&_svg]:size-4 outline-none focus-visible:ring-2 focus-visible:ring-fd-ring',
|
||||
{
|
||||
variants: {
|
||||
active: {
|
||||
true: 'bg-fd-primary/10 text-fd-primary',
|
||||
false: 'text-fd-muted-foreground hover:bg-fd-accent hover:text-fd-accent-foreground',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export function SidebarDrawer() {
|
||||
const { menuItems, slots } = useGlassLayout();
|
||||
const { root } = useTreeContext();
|
||||
const { setOpen } = useSidebar();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarDrawerOverlay className="fixed z-40 inset-0 bg-fd-overlay backdrop-blur-sm data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out" />
|
||||
<SidebarDrawerContent className="fixed z-40 inset-e-0 inset-y-0 flex flex-col w-[360px] max-w-[calc(100vw-3rem)] border-s bg-fd-background text-fd-foreground shadow-md data-[state=open]:animate-fd-sidebar-in data-[state=closed]:animate-fd-sidebar-out">
|
||||
<div className="flex flex-col px-3 size-full overflow-y-auto overscroll-contain fd-scroll-container">
|
||||
<div className="sticky flex items-center top-0 py-2 bg-fd-background shadow-lg shadow-fd-background">
|
||||
<div className="px-3 flex-1">
|
||||
<slots.navTitle className="inline-flex items-center font-semibold gap-2" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('Close Sidebar', { note: 'aria-label' })}
|
||||
onClick={() => setOpen(false)}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'secondary', size: 'icon-sm' }),
|
||||
'rounded-full',
|
||||
)}
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col py-2 flex-1">
|
||||
{menuItems.map(
|
||||
(item, i) => item.type !== 'icon' && <SidebarLinkItem key={i} item={item} />,
|
||||
)}
|
||||
{root.children.map((item, i) => cloneElement(renderNode(item), { key: i }))}
|
||||
</div>
|
||||
<div className="flex items-center sticky bottom-0 bg-fd-background px-1.5 pt-2 pb-4 border-t mt-2 empty:hidden">
|
||||
{menuItems.map(
|
||||
(item, i) => item.type === 'icon' && <SidebarIconLinkItem key={i} item={item} />,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarDrawerContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ className, children, ...props }: SidebarProps) {
|
||||
const { menuItems, slots } = useGlassLayout();
|
||||
const { root } = useTreeContext();
|
||||
|
||||
return (
|
||||
<aside
|
||||
id="nd-sidebar"
|
||||
className={cn(
|
||||
'sticky flex flex-col [grid-area:left] my-2 ms-2 z-20 top-2 border rounded-2xl bg-fd-popover/80 text-fd-popover-foreground shadow-md h-[calc(100dvh---spacing(4))] max-md:hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center text-sm gap-2 ps-4 p-2 h-10 border-b border-fd-foreground/10">
|
||||
<slots.navTitle className="inline-flex items-center font-semibold gap-2" />
|
||||
{slots.themeSwitch && <slots.themeSwitch className="ms-auto shrink-0 p-0 max-md:hidden" />}
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<ScrollViewport className="flex flex-col p-2 [mask-image:linear-gradient(to_bottom,transparent,white_16px,white_calc(100%-16px),transparent))]">
|
||||
{menuItems.map(
|
||||
(item, i) => item.type !== 'icon' && <SidebarLinkItem key={i} item={item} />,
|
||||
)}
|
||||
{root.children.map((item, i) => cloneElement(renderNode(item), { key: i }))}
|
||||
</ScrollViewport>
|
||||
</ScrollArea>
|
||||
<div className="flex items-center px-3.5 py-2 border-t mt-2 empty:hidden">
|
||||
{menuItems.map(
|
||||
(item, i) => item.type === 'icon' && <SidebarIconLinkItem key={i} item={item} />,
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarIconLinkItem({ item, className }: { item: IconItemType; className?: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={item.url}
|
||||
external={item.external}
|
||||
aria-label={item.label}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
|
||||
'text-fd-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLinkItem({
|
||||
item,
|
||||
className,
|
||||
}: {
|
||||
item: Exclude<LinkItemType, { type: 'icon' }>;
|
||||
className?: string;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
switch (item.type) {
|
||||
case 'custom':
|
||||
return <div className={className}>{item.children}</div>;
|
||||
case 'menu': {
|
||||
const rightIcon = (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'ms-auto text-fd-muted-foreground size-3.5! transition-transform',
|
||||
!open && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className={cn('mt-4 first:mt-0', className)}
|
||||
>
|
||||
{item.url ? (
|
||||
<div className="flex w-full text-sm px-3 py-1.5 font-medium">
|
||||
<Link
|
||||
href={item.url}
|
||||
external={item.external}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 [&_svg]:size-4',
|
||||
isLinkItemActive(item, pathname)
|
||||
? 'text-fd-primary'
|
||||
: 'hover:underline hover:decoration-fd-muted-foreground hover:underline-offset-4 hover:decoration-dashed hover:text-fd-accent-foreground',
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.text}
|
||||
</Link>
|
||||
<CollapsibleTrigger className="flex-1">{rightIcon}</CollapsibleTrigger>
|
||||
</div>
|
||||
) : (
|
||||
<CollapsibleTrigger className="w-full text-sm px-3 py-1.5 font-medium inline-flex items-center gap-2 [&_svg]:size-4">
|
||||
{item.icon}
|
||||
{item.text}
|
||||
{rightIcon}
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
|
||||
<CollapsibleContent className="flex flex-col">
|
||||
{item.items.map((item, i) => (
|
||||
<SidebarLinkItem key={i} item={item} />
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<Link
|
||||
href={item.url}
|
||||
external={item.external}
|
||||
className={cn(
|
||||
itemTriggerVariants({
|
||||
active: isLinkItemActive(item, pathname),
|
||||
className,
|
||||
}),
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.text}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderNode(node: PageTree.Node) {
|
||||
if (node.type === 'page') return <SidebarItem item={node} />;
|
||||
if (node.type === 'folder') return <SidebarFolder folder={node} />;
|
||||
|
||||
return (
|
||||
<p className="mt-4 w-full text-sm px-3 py-1.5 font-medium inline-flex items-center gap-2 [&_svg]:size-4 empty:hidden first:mt-0">
|
||||
{node.icon}
|
||||
{node.name}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarItem({ item }: { item: PageTree.Item }) {
|
||||
const path = useTreePath();
|
||||
return (
|
||||
<Link
|
||||
href={item.url}
|
||||
external={item.external}
|
||||
className={itemTriggerVariants({
|
||||
active: isNodeInPath(item, path),
|
||||
})}
|
||||
>
|
||||
{item.icon}
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFolder({ folder }: { folder: PageTree.Folder }) {
|
||||
const path = useTreePath();
|
||||
const shouldOpen = (folder.defaultOpen ?? true) || isNodeInPath(folder, path);
|
||||
const [open, setOpen] = useState(shouldOpen);
|
||||
|
||||
useOnChange(shouldOpen, () => shouldOpen && setOpen(true));
|
||||
if (folder.collapsible === false) {
|
||||
return (
|
||||
<>
|
||||
{folder.index ? (
|
||||
<Link
|
||||
href={folder.index.url}
|
||||
external={folder.index.external}
|
||||
className={cn(
|
||||
'inline-flex text-sm px-3 py-1.5 font-medium items-center gap-2 [&_svg]:size-4 mt-4 first:mt-0',
|
||||
isNodeInPath(folder.index, path)
|
||||
? 'text-fd-primary'
|
||||
: 'hover:underline hover:decoration-fd-muted-foreground hover:underline-offset-4 hover:decoration-dashed hover:text-fd-accent-foreground',
|
||||
)}
|
||||
>
|
||||
{folder.icon}
|
||||
{folder.name}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="w-full text-sm px-3 py-1.5 font-medium inline-flex items-center gap-2 [&_svg]:size-4 mt-4 first:mt-0">
|
||||
{folder.icon}
|
||||
{folder.name}
|
||||
</div>
|
||||
)}
|
||||
{folder.children.map((item, i) => cloneElement(renderNode(item), { key: i }))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const rightIcon = (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'ms-auto text-fd-muted-foreground size-3.5! transition-transform',
|
||||
!open && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="mt-4 first:mt-0">
|
||||
{folder.index ? (
|
||||
<div className="flex w-full text-sm px-3 py-1.5 font-medium">
|
||||
<Link
|
||||
href={folder.index.url}
|
||||
external={folder.index.external}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 [&_svg]:size-4',
|
||||
isNodeInPath(folder.index, path)
|
||||
? 'text-fd-primary'
|
||||
: 'hover:underline hover:decoration-fd-muted-foreground hover:underline-offset-4 hover:decoration-dashed hover:text-fd-accent-foreground',
|
||||
)}
|
||||
>
|
||||
{folder.icon}
|
||||
{folder.name}
|
||||
</Link>
|
||||
<CollapsibleTrigger className="flex-1">{rightIcon}</CollapsibleTrigger>
|
||||
</div>
|
||||
) : (
|
||||
<CollapsibleTrigger className="w-full text-sm px-3 py-1.5 font-medium inline-flex items-center gap-2 [&_svg]:size-4">
|
||||
{folder.icon}
|
||||
{folder.name}
|
||||
{rightIcon}
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
|
||||
<CollapsibleContent className="flex flex-col">
|
||||
{folder.children.map((item, i) => cloneElement(renderNode(item), { key: i }))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function isNodeInPath(node: PageTree.Node, path: PageTree.Node[]) {
|
||||
return path.some((other) => other.$id === node.$id || other === node);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ async function compileInline() {
|
||||
const commonNames = scanner.scan();
|
||||
await writeFile('css/generated/shared.css', namesToFile(commonNames));
|
||||
|
||||
const layouts = ['flux', 'notebook', 'home', 'docs'];
|
||||
const layouts = ['flux', 'notebook', 'home', 'docs', 'glass'];
|
||||
const commonNameSet = new Set(commonNames);
|
||||
|
||||
for (const layout of layouts) {
|
||||
|
||||
Reference in New Issue
Block a user