chore: update to Fumadocs 15.7 (#4154)

Co-authored-by: KinfeMichael Tariku <65047246+Kinfe123@users.noreply.github.com>
Co-authored-by: Kinfe123 <kinfishtech@gmail.com>
This commit is contained in:
Fuma Nama
2025-08-23 10:00:45 +08:00
committed by GitHub
parent c45ad901ce
commit d20f9a763c
32 changed files with 6469 additions and 4588 deletions

View File

@@ -0,0 +1,49 @@
"use client";
import { Menu, X } from "lucide-react";
import { type ButtonHTMLAttributes, type HTMLAttributes } from "react";
import { cn } from "../../lib/utils";
import { buttonVariants } from "./ui/button";
import { useSidebar } from "fumadocs-ui/provider";
import { useNav } from "./layout/nav";
import { SidebarTrigger } from "fumadocs-core/sidebar";
export function Navbar(props: HTMLAttributes<HTMLElement>) {
const { open } = useSidebar();
const { isTransparent } = useNav();
return (
<header
id="nd-subnav"
{...props}
className={cn(
"sticky top-(--fd-banner-height) z-30 flex h-14 flex-row items-center border-b border-fd-foreground/10 px-4 backdrop-blur-lg transition-colors",
(!isTransparent || open) && "bg-fd-background/80",
props.className,
)}
>
{props.children}
</header>
);
}
export function NavbarSidebarTrigger(
props: ButtonHTMLAttributes<HTMLButtonElement>,
) {
const { open } = useSidebar();
return (
<SidebarTrigger
{...props}
className={cn(
buttonVariants({
color: "ghost",
size: "icon",
}),
props.className,
)}
>
{open ? <X /> : <Menu />}
</SidebarTrigger>
);
}

View File

@@ -0,0 +1,57 @@
import type { PageTree } from "fumadocs-core/server";
import { type ReactNode, type HTMLAttributes } from "react";
import { cn } from "../../lib/utils";
import { type BaseLayoutProps } from "./shared";
import { TreeContextProvider } from "fumadocs-ui/provider";
import { NavProvider } from "./layout/nav";
import { type PageStyles, StylesProvider } from "fumadocs-ui/provider";
import ArticleLayout from "../side-bar";
export interface DocsLayoutProps extends BaseLayoutProps {
tree: PageTree.Root;
containerProps?: HTMLAttributes<HTMLDivElement>;
}
export function DocsLayout({ children, ...props }: DocsLayoutProps): ReactNode {
const variables = cn(
"[--fd-tocnav-height:36px] md:[--fd-sidebar-width:268px] lg:[--fd-sidebar-width:286px] xl:[--fd-toc-width:286px] xl:[--fd-tocnav-height:0px]",
);
const pageStyles: PageStyles = {
tocNav: cn("xl:hidden"),
toc: cn("max-xl:hidden"),
};
return (
<TreeContextProvider tree={props.tree}>
<NavProvider>
<main
id="nd-docs-layout"
{...props.containerProps}
className={cn(
"flex flex-1 flex-row pe-(--fd-layout-offset)",
variables,
props.containerProps?.className,
)}
style={
{
"--fd-layout-offset":
"max(calc(50vw - var(--fd-layout-width) / 2), 0px)",
...props.containerProps?.style,
} as object
}
>
<div
className={cn(
"[--fd-tocnav-height:36px] md:mr-[268px] lg:mr-[286px] xl:[--fd-toc-width:286px] xl:[--fd-tocnav-height:0px] ",
)}
>
<ArticleLayout />
</div>
<StylesProvider {...pageStyles}>{children}</StylesProvider>
</main>
</NavProvider>
</TreeContextProvider>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import Link, { type LinkProps } from "fumadocs-core/link";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { cn } from "../../../lib/utils";
import { useI18n } from "fumadocs-ui/provider";
export interface NavProviderProps {
/**
* Use transparent background
*
* @defaultValue none
*/
transparentMode?: "always" | "top" | "none";
}
export interface TitleProps {
title?: ReactNode;
/**
* Redirect url of title
* @defaultValue '/'
*/
url?: string;
}
interface NavContextType {
isTransparent: boolean;
}
const NavContext = createContext<NavContextType>({
isTransparent: false,
});
export function NavProvider({
transparentMode = "none",
children,
}: NavProviderProps & { children: ReactNode }) {
const [transparent, setTransparent] = useState(transparentMode !== "none");
useEffect(() => {
if (transparentMode !== "top") return;
const listener = () => {
setTransparent(window.scrollY < 10);
};
listener();
window.addEventListener("scroll", listener);
return () => {
window.removeEventListener("scroll", listener);
};
}, [transparentMode]);
return (
<NavContext.Provider
value={useMemo(() => ({ isTransparent: transparent }), [transparent])}
>
{children}
</NavContext.Provider>
);
}
export function useNav(): NavContextType {
return useContext(NavContext);
}
export function Title({
title,
url,
...props
}: TitleProps & Omit<LinkProps, "title">) {
const { locale } = useI18n();
return (
<Link
href={url ?? (locale ? `/${locale}` : "/")}
{...props}
className={cn(
"inline-flex items-center gap-2.5 font-semibold",
props.className,
)}
>
{title}
</Link>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
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 { cn } from "../../../lib/utils";
const itemVariants = cva(
"size-6.5 rounded-full p-1.5 text-fd-muted-foreground",
{
variants: {
active: {
true: "bg-fd-accent text-fd-accent-foreground",
false: "text-fd-muted-foreground",
},
},
},
);
const full = [
["light", Sun] as const,
["dark", Moon] as const,
["system", Airplay] as const,
];
export function ThemeToggle({
className,
mode = "light-dark",
...props
}: HTMLAttributes<HTMLElement> & {
mode?: "light-dark" | "light-dark-system";
}) {
const { setTheme, theme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useLayoutEffect(() => {
setMounted(true);
}, []);
const container = cn(
"inline-flex items-center rounded-full border p-1",
className,
);
if (mode === "light-dark") {
const value = mounted ? resolvedTheme : null;
return (
<button
className={container}
aria-label={`Toggle Theme`}
onClick={() => setTheme(value === "light" ? "dark" : "light")}
data-theme-toggle=""
{...props}
>
{full.map(([key, Icon]) => {
if (key === "system") return;
return (
<Icon
key={key}
fill="currentColor"
className={cn(itemVariants({ active: value === key }))}
/>
);
})}
</button>
);
}
const value = mounted ? theme : null;
return (
<div className={container} data-theme-toggle="" {...props}>
{full.map(([key, Icon]) => (
<button
key={key}
aria-label={key}
className={cn(itemVariants({ active: value === key }))}
onClick={() => setTheme(key)}
>
<Icon className="size-full" fill="currentColor" />
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { type HTMLAttributes, type RefObject, useEffect, useRef } from "react";
import * as Primitive from "fumadocs-core/toc";
import { useOnChange } from "fumadocs-core/utils/use-on-change";
import { useEffectEvent } from "fumadocs-core/utils/use-effect-event";
export type TOCThumb = [top: number, height: number];
function calc(container: HTMLElement, active: string[]): TOCThumb {
if (active.length === 0 || container.clientHeight === 0) {
return [0, 0];
}
let upper = Number.MAX_VALUE,
lower = 0;
for (const item of active) {
const element = container.querySelector<HTMLElement>(`a[href="#${item}"]`);
if (!element) continue;
const styles = getComputedStyle(element);
upper = Math.min(upper, element.offsetTop + parseFloat(styles.paddingTop));
lower = Math.max(
lower,
element.offsetTop +
element.clientHeight -
parseFloat(styles.paddingBottom),
);
}
return [upper, lower - upper];
}
function update(element: HTMLElement, info: TOCThumb): void {
element.style.setProperty("--fd-top", `${info[0]}px`);
element.style.setProperty("--fd-height", `${info[1]}px`);
}
export function TocThumb({
containerRef,
...props
}: HTMLAttributes<HTMLDivElement> & {
containerRef: RefObject<HTMLElement | null>;
}) {
const active = Primitive.useActiveAnchors();
const thumbRef = useRef<HTMLDivElement>(null);
const onResize = useEffectEvent(() => {
if (!containerRef.current || !thumbRef.current) return;
update(thumbRef.current, calc(containerRef.current, active));
});
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
onResize();
const observer = new ResizeObserver(onResize);
observer.observe(container);
return () => {
observer.disconnect();
};
}, [containerRef, onResize]);
useOnChange(active, () => {
if (!containerRef.current || !thumbRef.current) return;
update(thumbRef.current, calc(containerRef.current, active));
});
return <div ref={thumbRef} role="none" {...props} />;
}

View File

@@ -0,0 +1,345 @@
"use client";
import type { TOCItemType } from "fumadocs-core/server";
import * as Primitive from "fumadocs-core/toc";
import {
type ComponentProps,
createContext,
type HTMLAttributes,
type ReactNode,
use,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
import { useI18n } from "fumadocs-ui/provider";
import { TocThumb } from "./toc-thumb";
import { ScrollArea, ScrollViewport } from "../ui/scroll-area";
import type {
PopoverContentProps,
PopoverTriggerProps,
} from "@radix-ui/react-popover";
import { ChevronRight, Text } from "lucide-react";
import { usePageStyles } from "fumadocs-ui/provider";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "../ui/collapsible";
export interface TOCProps {
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
children: ReactNode;
}
export function Toc(props: HTMLAttributes<HTMLDivElement>) {
const { toc } = usePageStyles();
return (
<div
id="nd-toc"
{...props}
className={cn(
"sticky top-[calc(var(--fd-banner-height)+var(--fd-nav-height))] h-(--fd-toc-height) pb-2 pt-12",
toc,
props.className,
)}
style={
{
...props.style,
"--fd-toc-height":
"calc(100dvh - var(--fd-banner-height) - var(--fd-nav-height))",
} as object
}
>
<div className="flex h-full w-(--fd-toc-width) max-w-full flex-col gap-3 pe-4">
{props.children}
</div>
</div>
);
}
export function TocItemsEmpty() {
const { text } = useI18n();
return (
<div className="rounded-lg border bg-fd-card p-3 text-xs text-fd-muted-foreground">
{text.tocNoHeadings}
</div>
);
}
export function TOCScrollArea({
isMenu,
...props
}: ComponentProps<typeof ScrollArea> & { isMenu?: boolean }) {
const viewRef = useRef<HTMLDivElement>(null);
return (
<ScrollArea
{...props}
className={cn("flex flex-col ps-px", props.className)}
>
<Primitive.ScrollProvider containerRef={viewRef}>
<ScrollViewport
className={cn(
"relative min-h-0 text-sm",
isMenu && "mt-2 mb-4 mx-4 md:mx-6",
)}
ref={viewRef}
>
{props.children}
</ScrollViewport>
</Primitive.ScrollProvider>
</ScrollArea>
);
}
export function TOCItems({ items }: { items: TOCItemType[] }) {
const containerRef = useRef<HTMLDivElement>(null);
const [svg, setSvg] = useState<{
path: string;
width: number;
height: number;
}>();
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
function onResize(): void {
if (container.clientHeight === 0) return;
let w = 0,
h = 0;
const d: string[] = [];
for (let i = 0; i < items.length; i++) {
const element: HTMLElement | null = container.querySelector(
`a[href="#${items[i].url.slice(1)}"]`,
);
if (!element) continue;
const styles = getComputedStyle(element);
const offset = getLineOffset(items[i].depth) + 1,
top = element.offsetTop + parseFloat(styles.paddingTop),
bottom =
element.offsetTop +
element.clientHeight -
parseFloat(styles.paddingBottom);
w = Math.max(offset, w);
h = Math.max(h, bottom);
d.push(`${i === 0 ? "M" : "L"}${offset} ${top}`);
d.push(`L${offset} ${bottom}`);
}
setSvg({
path: d.join(" "),
width: w + 1,
height: h,
});
}
const observer = new ResizeObserver(onResize);
onResize();
observer.observe(container);
return () => {
observer.disconnect();
};
}, [items]);
if (items.length === 0) return <TocItemsEmpty />;
return (
<>
{svg ? (
<div
className="absolute start-0 top-0 rtl:-scale-x-100"
style={{
width: svg.width,
height: svg.height,
maskImage: `url("data:image/svg+xml,${
// Inline SVG
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svg.width} ${svg.height}"><path d="${svg.path}" stroke="black" stroke-width="1" fill="none" /></svg>`,
)
}")`,
}}
>
<TocThumb
containerRef={containerRef}
className="mt-(--fd-top) h-(--fd-height) bg-fd-primary transition-all"
/>
</div>
) : null}
<div className="flex flex-col" ref={containerRef}>
{items.map((item, i) => (
<TOCItem
key={item.url}
item={item}
upper={items[i - 1]?.depth}
lower={items[i + 1]?.depth}
/>
))}
</div>
</>
);
}
function getItemOffset(depth: number): number {
if (depth <= 2) return 14;
if (depth === 3) return 26;
return 36;
}
function getLineOffset(depth: number): number {
return depth >= 3 ? 10 : 0;
}
function TOCItem({
item,
upper = item.depth,
lower = item.depth,
}: {
item: TOCItemType;
upper?: number;
lower?: number;
}) {
const offset = getLineOffset(item.depth),
upperOffset = getLineOffset(upper),
lowerOffset = getLineOffset(lower);
return (
<Primitive.TOCItem
href={item.url}
style={{
paddingInlineStart: getItemOffset(item.depth),
}}
className="prose relative 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"
>
{offset !== upperOffset ? (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
className="absolute -top-1.5 start-0 size-4 rtl:-scale-x-100"
>
<line
x1={upperOffset}
y1="0"
x2={offset}
y2="12"
className="stroke-fd-foreground/10"
strokeWidth="1"
/>
</svg>
) : null}
<div
className={cn(
"absolute inset-y-0 w-px bg-fd-foreground/10",
offset !== upperOffset && "top-1.5",
offset !== lowerOffset && "bottom-1.5",
)}
style={{
insetInlineStart: offset,
}}
/>
{item.title}
</Primitive.TOCItem>
);
}
type MakeRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
const Context = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);
const TocProvider = Context.Provider || Context;
export function TocPopover({
open,
onOpenChange,
ref: _ref,
...props
}: MakeRequired<ComponentProps<typeof Collapsible>, "open" | "onOpenChange">) {
return (
<Collapsible open={open} onOpenChange={onOpenChange} {...props}>
<TocProvider
value={useMemo(
() => ({
open,
setOpen: onOpenChange,
}),
[onOpenChange, open],
)}
>
{props.children}
</TocProvider>
</Collapsible>
);
}
export function TocPopoverTrigger({
items,
...props
}: PopoverTriggerProps & { items: TOCItemType[] }) {
const { text } = useI18n();
const { open } = use(Context)!;
const active = Primitive.useActiveAnchor();
const current = useMemo(() => {
return items.find((item) => active === item.url.slice(1))?.title;
}, [items, active]);
return (
<CollapsibleTrigger
{...props}
className={cn(
"inline-flex items-center text-sm gap-2 text-nowrap px-4 py-2.5 text-start md:px-6 focus-visible:outline-none",
props.className,
)}
>
<Text className="size-4 shrink-0" />
{text.toc}
<ChevronRight
className={cn(
"size-4 shrink-0 text-fd-muted-foreground transition-all",
!current && "opacity-0",
open ? "rotate-90" : "-ms-1.5",
)}
/>
<span
className={cn(
"truncate text-fd-muted-foreground transition-opacity -ms-1.5",
(!current || open) && "opacity-0",
)}
>
{current}
</span>
</CollapsibleTrigger>
);
}
export function TocPopoverContent(props: PopoverContentProps) {
return (
<CollapsibleContent
data-toc-popover=""
className="flex flex-col max-h-[50vh]"
{...props}
>
{props.children}
</CollapsibleContent>
);
}

View File

@@ -0,0 +1,254 @@
"use client";
import {
Fragment,
type HTMLAttributes,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import Link from "next/link";
import { cva } from "class-variance-authority";
import { cn } from "../../lib/utils";
import { useI18n } from "fumadocs-ui/provider";
import { useTreeContext, useTreePath } from "fumadocs-ui/provider";
import { useSidebar } from "fumadocs-ui/provider";
import type { PageTree } from "fumadocs-core/server";
import { usePathname } from "next/navigation";
import { useNav } from "./layout/nav";
import {
type BreadcrumbOptions,
getBreadcrumbItemsFromPath,
} from "fumadocs-core/breadcrumb";
import { usePageStyles } from "fumadocs-ui/provider";
import { isActive } from "../../lib/is-active";
import { TocPopover } from "./layout/toc";
import { useEffectEvent } from "fumadocs-core/utils/use-effect-event";
export function TocPopoverHeader(props: HTMLAttributes<HTMLDivElement>) {
const ref = useRef<HTMLElement>(null);
const [open, setOpen] = useState(false);
const sidebar = useSidebar();
const { tocNav } = usePageStyles();
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);
};
}, [onClick]);
return (
<div
className={cn("sticky overflow-visible z-10", tocNav, props.className)}
style={{
top: "calc(var(--fd-banner-height) + var(--fd-nav-height))",
}}
>
<TocPopover open={open} onOpenChange={setOpen} asChild>
<header
ref={ref}
id="nd-tocnav"
{...props}
className={cn(
"border-b border-fd-foreground/10 backdrop-blur-md transition-colors",
(!isTransparent || open) && "bg-fd-background/80",
open && "shadow-lg",
sidebar.open && "max-md:hidden",
)}
>
{props.children}
</header>
</TocPopover>
</div>
);
}
export function PageBody(props: HTMLAttributes<HTMLDivElement>) {
const { page } = usePageStyles();
return (
<div
id="nd-page"
{...props}
className={cn("flex w-full min-w-0 flex-col", page, props.className)}
>
{props.children}
</div>
);
}
export function PageArticle(props: HTMLAttributes<HTMLElement>) {
const { article } = usePageStyles();
return (
<article
{...props}
className={cn(
"flex w-full flex-1 flex-col gap-6 px-4 pt-8 md:px-6 md:pt-12 xl:px-12 xl:mx-auto",
article,
props.className,
)}
>
{props.children}
</article>
);
}
export function LastUpdate(props: { date: Date }) {
const { text } = useI18n();
const [date, setDate] = useState("");
useEffect(() => {
// to the timezone of client
setDate(props.date.toLocaleDateString());
}, [props.date]);
return (
<p className="text-sm text-fd-muted-foreground">
{text.lastUpdate} {date}
</p>
);
}
export interface FooterProps {
/**
* Items including information for the next and previous page
*/
items?: {
previous?: { name: string; url: string };
next?: { name: string; url: string };
};
}
const itemVariants = cva(
"flex w-full flex-col gap-2 rounded-lg border p-4 text-sm transition-colors hover:bg-fd-accent/80 hover:text-fd-accent-foreground",
);
const itemLabel = cva(
"inline-flex items-center gap-0.5 text-fd-muted-foreground",
);
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 WeakMap<PageTree.Root, PageTree.Item[]>();
export function Footer({ items }: FooterProps) {
const { root } = useTreeContext();
const { text } = useI18n();
const pathname = usePathname();
const { previous, next } = useMemo(() => {
if (items) return items;
const cached = listCache.get(root);
const list = cached ?? scanNavigationList(root.children);
listCache.set(root, 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 className="grid grid-cols-2 gap-4 pb-6">
{previous ? (
<Link href={previous.url} className={cn(itemVariants())}>
<div className={cn(itemLabel())}>
<ChevronLeft className="-ms-1 size-4 shrink-0 rtl:rotate-180" />
<p>{text.previousPage}</p>
</div>
<p className="font-medium md:text-[15px]">{previous.name}</p>
</Link>
) : null}
{next ? (
<Link
href={next.url}
className={cn(itemVariants({ className: "col-start-2 text-end" }))}
>
<div className={cn(itemLabel({ className: "flex-row-reverse" }))}>
<ChevronRight className="-me-1 size-4 shrink-0 rtl:rotate-180" />
<p>{text.nextPage}</p>
</div>
<p className="font-medium md:text-[15px]">{next.name}</p>
</Link>
) : null}
</div>
);
}
export type BreadcrumbProps = BreadcrumbOptions;
export function Breadcrumb(options: BreadcrumbProps) {
const path = useTreePath();
const { root } = useTreeContext();
const items = useMemo(() => {
return getBreadcrumbItemsFromPath(root, path, {
includePage: options.includePage ?? false,
...options,
});
}, [options, path, root]);
if (items.length === 0) return null;
return (
<div className="flex flex-row items-center gap-1.5 text-[15px] text-fd-muted-foreground">
{items.map((item, i) => {
const className = cn(
"truncate",
i === items.length - 1 && "text-fd-primary font-medium",
);
return (
<Fragment key={i}>
{i !== 0 && <span className="text-fd-foreground/30">/</span>}
{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>
);
}

View File

@@ -0,0 +1,298 @@
import type { TableOfContents } from "fumadocs-core/server";
import {
type AnchorHTMLAttributes,
forwardRef,
type HTMLAttributes,
type ReactNode,
} from "react";
import { type AnchorProviderProps, AnchorProvider } from "fumadocs-core/toc";
import { replaceOrDefault } from "./shared";
import { cn } from "../../lib/utils";
import {
Footer,
type FooterProps,
LastUpdate,
TocPopoverHeader,
type BreadcrumbProps,
PageBody,
PageArticle,
} from "./page.client";
import {
Toc,
TOCItems,
TocPopoverTrigger,
TocPopoverContent,
type TOCProps,
TOCScrollArea,
} from "./layout/toc";
import { buttonVariants } from "./ui/button";
import { Edit, Text } from "lucide-react";
import { I18nLabel } from "fumadocs-ui/provider";
type TableOfContentOptions = Omit<TOCProps, "items" | "children"> &
Pick<AnchorProviderProps, "single"> & {
enabled: boolean;
component: ReactNode;
};
type TableOfContentPopoverOptions = Omit<TableOfContentOptions, "single">;
interface EditOnGitHubOptions
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "children"> {
owner: string;
repo: string;
/**
* SHA or ref (branch or tag) name.
*
* @defaultValue main
*/
sha?: string;
/**
* File path in the repo
*/
path: string;
}
interface BreadcrumbOptions extends BreadcrumbProps {
enabled: boolean;
component: ReactNode;
/**
* Show the full path to the current page
*
* @defaultValue false
* @deprecated use `includePage` instead
*/
full?: boolean;
}
interface FooterOptions extends FooterProps {
enabled: boolean;
component: ReactNode;
}
export interface DocsPageProps {
toc?: TableOfContents;
/**
* Extend the page to fill all available space
*
* @defaultValue false
*/
full?: boolean;
tableOfContent?: Partial<TableOfContentOptions>;
tableOfContentPopover?: Partial<TableOfContentPopoverOptions>;
/**
* Replace or disable breadcrumb
*/
breadcrumb?: Partial<BreadcrumbOptions>;
/**
* Footer navigation, you can disable it by passing `false`
*/
footer?: Partial<FooterOptions>;
editOnGithub?: EditOnGitHubOptions;
lastUpdate?: Date | string | number;
container?: HTMLAttributes<HTMLDivElement>;
article?: HTMLAttributes<HTMLElement>;
children: ReactNode;
}
export function DocsPage({
toc = [],
full = false,
tableOfContentPopover: {
enabled: tocPopoverEnabled,
component: tocPopoverReplace,
...tocPopoverOptions
} = {},
tableOfContent: {
enabled: tocEnabled,
component: tocReplace,
...tocOptions
} = {},
...props
}: DocsPageProps) {
const isTocRequired =
toc.length > 0 ||
tocOptions.footer !== undefined ||
tocOptions.header !== undefined;
// disable TOC on full mode, you can still enable it with `enabled` option.
tocEnabled ??= !full && isTocRequired;
tocPopoverEnabled ??=
toc.length > 0 ||
tocPopoverOptions.header !== undefined ||
tocPopoverOptions.footer !== undefined;
return (
<AnchorProvider toc={toc} single={tocOptions.single}>
<PageBody
{...props.container}
className={cn(props.container?.className)}
style={
{
"--fd-tocnav-height": !tocPopoverEnabled ? "0px" : undefined,
...props.container?.style,
} as object
}
>
{replaceOrDefault(
{ enabled: tocPopoverEnabled, component: tocPopoverReplace },
<TocPopoverHeader className="h-10">
<TocPopoverTrigger className="w-full" items={toc} />
<TocPopoverContent>
{tocPopoverOptions.header}
<TOCScrollArea isMenu>
<TOCItems items={toc} />
</TOCScrollArea>
{tocPopoverOptions.footer}
</TocPopoverContent>
</TocPopoverHeader>,
{
items: toc,
...tocPopoverOptions,
},
)}
<PageArticle
{...props.article}
className={cn(
full || !tocEnabled ? "max-w-[1120px]" : "max-w-[860px]",
props.article?.className,
)}
>
{props.children}
<div role="none" className="flex-1" />
<div className="flex flex-row flex-wrap items-center justify-between gap-4 empty:hidden">
{props.editOnGithub ? (
<EditOnGitHub {...props.editOnGithub} />
) : null}
{props.lastUpdate ? (
<LastUpdate date={new Date(props.lastUpdate)} />
) : null}
</div>
{replaceOrDefault(
props.footer,
<Footer items={props.footer?.items} />,
)}
</PageArticle>
</PageBody>
{replaceOrDefault(
{ enabled: tocEnabled, component: tocReplace },
<Toc>
{tocOptions.header}
<h3 className="inline-flex items-center gap-1.5 text-sm text-fd-muted-foreground">
<Text className="size-4" />
<I18nLabel label="toc" />
</h3>
<TOCScrollArea>
<TOCItems items={toc} />
</TOCScrollArea>
{tocOptions.footer}
</Toc>,
{
items: toc,
...tocOptions,
},
)}
</AnchorProvider>
);
}
function EditOnGitHub({
owner,
repo,
sha,
path,
...props
}: EditOnGitHubOptions) {
const href = `https://github.com/${owner}/${repo}/blob/${sha}/${path.startsWith("/") ? path.slice(1) : path}`;
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
{...props}
className={cn(
buttonVariants({
color: "secondary",
className: "gap-1.5 text-fd-muted-foreground",
}),
props.className,
)}
>
<Edit className="size-3.5" />
<I18nLabel label="editOnGithub" />
</a>
);
}
/**
* Add typography styles
*/
export const DocsBody = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>((props, ref) => (
<div ref={ref} {...props} className={cn("prose", props.className)}>
{props.children}
</div>
));
DocsBody.displayName = "DocsBody";
export const DocsDescription = forwardRef<
HTMLParagraphElement,
HTMLAttributes<HTMLParagraphElement>
>((props, ref) => {
// don't render if no description provided
if (props.children === undefined) return null;
return (
<p
ref={ref}
{...props}
className={cn("mb-8 text-lg text-fd-muted-foreground", props.className)}
>
{props.children}
</p>
);
});
DocsDescription.displayName = "DocsDescription";
export const DocsTitle = forwardRef<
HTMLHeadingElement,
HTMLAttributes<HTMLHeadingElement>
>((props, ref) => {
return (
<h1
ref={ref}
{...props}
className={cn("text-3xl font-semibold", props.className)}
>
{props.children}
</h1>
);
});
DocsTitle.displayName = "DocsTitle";
/**
* For separate MDX page
*/
export function withArticle({ children }: { children: ReactNode }): ReactNode {
return (
<main className="container py-12">
<article className="prose">{children}</article>
</main>
);
}

View File

@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
import { Slot } from "@radix-ui/react-slot";
export interface BaseLayoutProps {
children?: ReactNode;
}
export function replaceOrDefault(
obj:
| {
enabled?: boolean;
component?: ReactNode;
}
| undefined,
def: ReactNode,
customComponentProps?: object,
disabled?: ReactNode,
): ReactNode {
if (obj?.enabled === false) return disabled;
if (obj?.component !== undefined)
return <Slot {...customComponentProps}>{obj.component}</Slot>;
return def;
}

View File

@@ -0,0 +1,22 @@
import { cva } from "class-variance-authority";
export const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md p-2 text-sm font-medium transition-colors duration-100 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
color: {
primary:
"bg-fd-primary text-fd-primary-foreground hover:bg-fd-primary/80",
outline: "border hover:bg-fd-accent hover:text-fd-accent-foreground",
ghost: "hover:bg-fd-accent hover:text-fd-accent-foreground",
secondary:
"border bg-fd-secondary text-fd-secondary-foreground hover:bg-fd-accent hover:text-fd-accent-foreground",
},
size: {
sm: "gap-1 p-0.5 text-xs",
icon: "p-1.5 [&_svg]:size-5",
"icon-sm": "p-1.5 [&_svg]:size-4.5",
},
},
},
);

View File

@@ -0,0 +1,39 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import { forwardRef, useEffect, useState } from "react";
import { cn } from "../../../lib/utils";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
>(({ children, ...props }, ref) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<CollapsiblePrimitive.CollapsibleContent
ref={ref}
{...props}
className={cn(
"overflow-hidden",
mounted &&
"data-[state=closed]:animate-fd-collapsible-up data-[state=open]:animate-fd-collapsible-down",
props.className,
)}
>
{children}
</CollapsiblePrimitive.CollapsibleContent>
);
});
CollapsibleContent.displayName =
CollapsiblePrimitive.CollapsibleContent.displayName;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,32 @@
"use client";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as React from "react";
import { cn } from "../../../lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ComponentRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
side="bottom"
className={cn(
"z-50 min-w-[220px] max-w-[98vw] rounded-lg border bg-fd-popover p-2 text-sm text-fd-popover-foreground shadow-lg focus-visible:outline-none data-[state=closed]:animate-fd-popover-out data-[state=open]:animate-fd-popover-in",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
const PopoverClose = PopoverPrimitive.PopoverClose;
export { Popover, PopoverTrigger, PopoverContent, PopoverClose };

View File

@@ -0,0 +1,57 @@
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import * as React from "react";
import { cn } from "../../../lib/utils";
const ScrollArea = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("overflow-hidden", className)}
{...props}
>
{children}
<ScrollAreaPrimitive.Corner />
<ScrollBar orientation="vertical" />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollViewport = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Viewport>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Viewport
ref={ref}
className={cn("size-full rounded-[inherit]", className)}
{...props}
>
{children}
</ScrollAreaPrimitive.Viewport>
));
ScrollViewport.displayName = ScrollAreaPrimitive.Viewport.displayName;
const ScrollBar = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.Scrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Scrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.Scrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex select-none data-[state=hidden]:animate-fd-fade-out",
orientation === "vertical" && "h-full w-1.5",
orientation === "horizontal" && "h-1.5 flex-col",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-fd-border" />
</ScrollAreaPrimitive.Scrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.Scrollbar.displayName;
export { ScrollArea, ScrollBar, ScrollViewport };

View File

@@ -19,6 +19,7 @@ import {
} from "lucide-react";
import { ReactNode, SVGProps } from "react";
import { Icons } from "./icons";
import { PageTree } from "fumadocs-core/server";
interface Content {
title: string;
@@ -34,6 +35,51 @@ interface Content {
}[];
}
export function getPageTree(): PageTree.Root {
return {
$id: "root",
name: "docs",
children: [
{
type: "folder",
root: true,
name: "Docs",
description: "get started, concepts, and plugins.",
children: contents.map(contentToPageTree),
},
{
type: "folder",
root: true,
name: "Examples",
description: "exmaples and guides.",
children: examples.map(contentToPageTree),
},
],
};
}
function contentToPageTree(content: Content): PageTree.Folder {
return {
type: "folder",
icon: <content.Icon />,
name: content.title,
index: content.href
? {
icon: <content.Icon />,
name: content.title,
type: "page",
url: content.href,
}
: undefined,
children: content.list.map((item) => ({
type: "page",
url: item.href,
name: item.title,
icon: <item.icon />,
})),
};
}
export const contents: Content[] = [
{
title: "Get Started",

View File

@@ -0,0 +1,73 @@
import { CircleCheck, CircleX, Info, TriangleAlert } from "lucide-react";
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { cva } from "class-variance-authority";
type CalloutProps = Omit<
HTMLAttributes<HTMLDivElement>,
"title" | "type" | "icon"
> & {
title?: ReactNode;
/**
* @defaultValue info
*/
type?: "info" | "warn" | "error" | "success" | "warning";
/**
* Force an icon
*/
icon?: ReactNode;
};
const calloutVariants = cva(
"my-4 flex gap-2 rounded-lg border border-s-2 bg-fd-card p-3 text-sm text-fd-card-foreground shadow-md border-dashed rounded-none",
{
variants: {
type: {
info: "border-s-blue-500/50",
warn: "border-s-orange-500/50",
error: "border-s-red-500/50",
success: "border-s-green-500/50",
},
},
},
);
export const Callout = forwardRef<HTMLDivElement, CalloutProps>(
({ className, children, title, type = "info", icon, ...props }, ref) => {
if (type === "warning") type = "warn";
return (
<div
ref={ref}
className={cn(
calloutVariants({
type: type,
}),
className,
)}
{...props}
>
{icon ??
{
info: <Info className="size-5 fill-blue-500 text-fd-card" />,
warn: (
<TriangleAlert className="size-5 fill-orange-500 text-fd-card" />
),
error: <CircleX className="size-5 fill-red-500 text-fd-card" />,
success: (
<CircleCheck className="size-5 fill-green-500 text-fd-card" />
),
}[type]}
<div className="min-w-0 flex flex-col gap-2 flex-1">
{title ? <p className="font-medium !my-0">{title}</p> : null}
<div className="text-fd-muted-foreground prose-no-margin empty:hidden">
{children}
</div>
</div>
</div>
);
},
);
Callout.displayName = "Callout";

View File

@@ -0,0 +1,354 @@
"use client";
import { Check, Copy } from "lucide-react";
import {
ButtonHTMLAttributes,
type ComponentProps,
createContext,
forwardRef,
type HTMLAttributes,
ReactElement,
type ReactNode,
type RefObject,
useCallback,
useContext,
useMemo,
useRef,
} from "react";
import { cn } from "@/lib/utils";
import { useCopyButton } from "./use-copy-button";
import { buttonVariants } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { mergeRefs } from "@/lib/utils";
import { ScrollArea, ScrollBar, ScrollViewport } from "./scroll-area";
export interface CodeBlockProps extends ComponentProps<"figure"> {
/**
* Icon of code block
*
* When passed as a string, it assumes the value is the HTML of icon
*/
icon?: ReactNode;
/**
* Allow to copy code with copy button
*
* @defaultValue true
*/
allowCopy?: boolean;
/**
* Keep original background color generated by Shiki or Rehype Code
*
* @defaultValue false
*/
keepBackground?: boolean;
viewportProps?: HTMLAttributes<HTMLElement>;
/**
* show line numbers
*/
"data-line-numbers"?: boolean;
/**
* @defaultValue 1
*/
"data-line-numbers-start"?: number;
Actions?: (props: { className?: string; children?: ReactNode }) => ReactNode;
}
const TabsContext = createContext<{
containerRef: RefObject<HTMLDivElement | null>;
nested: boolean;
} | null>(null);
export function Pre(props: ComponentProps<"pre">) {
return (
<pre
{...props}
className={cn("min-w-full w-max *:flex *:flex-col", props.className)}
>
{props.children}
</pre>
);
}
export function CodeBlock({
ref,
title,
allowCopy,
keepBackground = false,
icon,
viewportProps = {},
children,
Actions = (props) => (
<div {...props} className={cn("empty:hidden", props.className)} />
),
...props
}: CodeBlockProps) {
const isTab = useContext(TabsContext) !== null;
const areaRef = useRef<HTMLDivElement>(null);
allowCopy ??= !isTab;
const bg = cn(
"bg-fd-secondary",
keepBackground && "bg-(--shiki-light-bg) dark:bg-(--shiki-dark-bg)",
);
const onCopy = useCallback(() => {
const pre = areaRef.current?.getElementsByTagName("pre").item(0);
if (!pre) return;
const clone = pre.cloneNode(true) as HTMLElement;
clone.querySelectorAll(".nd-copy-ignore").forEach((node) => {
node.remove();
});
void navigator.clipboard.writeText(clone.textContent ?? "");
}, []);
return (
<figure
ref={ref}
dir="ltr"
{...props}
className={cn(
isTab ? [bg, "rounded-lg"] : "my-4 rounded-lg bg-fd-card",
"group shiki relative border shadow-sm outline-none not-prose overflow-hidden text-sm",
props.className,
)}
>
{title ? (
<div
className={cn(
"group flex text-fd-muted-foreground items-center gap-2 ps-3 h-9.5 pr-1 bg-fd-muted",
isTab && "border-b",
)}
>
{typeof icon === "string" ? (
<div
className="[&_svg]:size-3.5"
dangerouslySetInnerHTML={{
__html: icon,
}}
/>
) : (
icon
)}
<figcaption className="flex-1 truncate">{title}</figcaption>
{Actions({
children: allowCopy && <CopyButton onCopy={onCopy} />,
})}
</div>
) : (
Actions({
className: "absolute top-1 right-1 z-2 text-fd-muted-foreground",
children: allowCopy && <CopyButton onCopy={onCopy} />,
})
)}
<div
ref={areaRef}
{...viewportProps}
className={cn(
!isTab && [bg, "rounded-none border border-x-0 border-b-0"],
"text-[13px] overflow-auto max-h-[600px] bg-fd-muted/50 fd-scroll-container",
viewportProps.className,
)}
style={
{
// space for toolbar
"--padding-right": !title ? "calc(var(--spacing) * 8)" : undefined,
counterSet: props["data-line-numbers"]
? `line ${Number(props["data-line-numbers-start"] ?? 1) - 1}`
: undefined,
...viewportProps.style,
} as object
}
>
{children}
</div>
</figure>
);
}
function CopyButton({
className,
onCopy,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
onCopy: () => void;
}): ReactElement {
const [checked, onClick] = useCopyButton(onCopy);
return (
<button
type="button"
className={cn(
buttonVariants({
variant: "ghost",
size: "icon",
}),
"transition-opacity border-none group-hover:opacity-100",
"opacity-0 group-hover:opacity-100",
"group-hover:opacity-100",
className,
)}
aria-label="Copy Text"
onClick={onClick}
{...props}
>
<Check
className={cn("size-3.5 transition-transform", !checked && "scale-0")}
/>
<Copy
className={cn(
"absolute size-3.5 transition-transform",
checked && "scale-0",
)}
/>
</button>
);
}
export function CodeBlockTabs({ ref, ...props }: ComponentProps<typeof Tabs>) {
const containerRef = useRef<HTMLDivElement>(null);
const nested = useContext(TabsContext) !== null;
return (
<Tabs
ref={mergeRefs(containerRef, ref)}
{...props}
className={cn(
"bg-fd-card p-1 rounded-xl border overflow-hidden",
!nested && "my-4",
props.className,
)}
>
<TabsContext.Provider
value={useMemo(
() => ({
containerRef,
nested,
}),
[nested],
)}
>
{props.children}
</TabsContext.Provider>
</Tabs>
);
}
export function CodeBlockTabsList(props: ComponentProps<typeof TabsList>) {
const { containerRef, nested } = useContext(TabsContext)!;
return (
<TabsList
{...props}
className={cn(
"flex flex-row overflow-x-auto px-1 -mx-1 text-fd-muted-foreground",
props.className,
)}
>
{props.children}
</TabsList>
);
}
export function CodeBlockTabsTrigger({
children,
...props
}: ComponentProps<typeof TabsTrigger>) {
return (
<TabsTrigger
{...props}
className={cn(
"relative group inline-flex text-sm font-medium text-nowrap items-center transition-colors gap-2 px-2 first:ms-1 py-1.5 hover:text-fd-accent-foreground data-[state=active]:text-fd-primary [&_svg]:size-3.5",
props.className,
)}
>
<div className="absolute inset-x-2 bottom-0 h-px group-data-[state=active]:bg-fd-primary" />
{children}
</TabsTrigger>
);
}
// TODO: currently Vite RSC plugin has problem with adding `asChild` here, maybe revisit this in future
export const CodeBlockTab = TabsContent;
export const CodeBlockOld = forwardRef<HTMLElement, CodeBlockProps>(
(
{
title,
allowCopy = true,
keepBackground = false,
icon,
viewportProps,
...props
},
ref,
) => {
const areaRef = useRef<HTMLDivElement>(null);
const onCopy = useCallback(() => {
const pre = areaRef.current?.getElementsByTagName("pre").item(0);
if (!pre) return;
const clone = pre.cloneNode(true) as HTMLElement;
clone.querySelectorAll(".nd-copy-ignore").forEach((node) => {
node.remove();
});
void navigator.clipboard.writeText(clone.textContent ?? "");
}, []);
return (
<figure
ref={ref}
{...props}
className={cn(
"not-prose group fd-codeblock relative my-6 overflow-hidden rounded-lg border bg-fd-secondary/50 text-sm",
keepBackground &&
"bg-[var(--shiki-light-bg)] dark:bg-[var(--shiki-dark-bg)]",
props.className,
)}
>
{title ? (
<div className="flex flex-row items-center gap-2 border-b bg-fd-muted px-4 py-1.5">
{icon ? (
<div
className="text-fd-muted-foreground [&_svg]:size-3.5"
dangerouslySetInnerHTML={
typeof icon === "string"
? {
__html: icon,
}
: undefined
}
>
{typeof icon !== "string" ? icon : null}
</div>
) : null}
<figcaption className="flex-1 truncate text-fd-muted-foreground">
{title}
</figcaption>
{allowCopy ? (
<CopyButton className="-me-2" onCopy={onCopy} />
) : null}
</div>
) : (
allowCopy && (
<CopyButton
className="absolute right-2 top-2 z-[2] backdrop-blur-md"
onCopy={onCopy}
/>
)
)}
<ScrollArea ref={areaRef} dir="ltr">
<ScrollViewport
{...viewportProps}
className={cn("max-h-[600px]", viewportProps?.className)}
>
{props.children}
</ScrollViewport>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</figure>
);
},
);
CodeBlockOld.displayName = "CodeBlockOld";

View File

@@ -27,6 +27,20 @@ function ScrollArea({
</ScrollAreaPrimitive.Root>
);
}
const ScrollViewport = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Viewport>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Viewport
ref={ref}
className={cn("size-full rounded-[inherit]", className)}
{...props}
>
{children}
</ScrollAreaPrimitive.Viewport>
));
ScrollViewport.displayName = ScrollAreaPrimitive.Viewport.displayName;
function ScrollBar({
className,
@@ -55,4 +69,4 @@ function ScrollBar({
);
}
export { ScrollArea, ScrollBar };
export { ScrollArea, ScrollBar, ScrollViewport };

View File

@@ -0,0 +1,31 @@
"use client";
import { type MouseEventHandler, useEffect, useRef, useState } from "react";
import { useEffectEvent } from "fumadocs-core/utils/use-effect-event";
export function useCopyButton(
onCopy: () => void | Promise<void>,
): [checked: boolean, onClick: MouseEventHandler] {
const [checked, setChecked] = useState(false);
const timeoutRef = useRef<number | null>(null);
const onClick: MouseEventHandler = useEffectEvent(() => {
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
const res = Promise.resolve(onCopy());
void res.then(() => {
setChecked(true);
timeoutRef.current = window.setTimeout(() => {
setChecked(false);
}, 1500);
});
});
// Avoid updates after being unmounted
useEffect(() => {
return () => {
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
};
}, []);
return [checked, onClick];
}