"use client";
import {
ArrowLeftIcon,
ArrowRightIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
CopyIcon,
InfoIcon,
LightbulbIcon,
MenuIcon,
SearchIcon,
ThumbsDownIcon,
ThumbsUpIcon,
TriangleAlertIcon,
XIcon,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion, useScroll } from "motion/react";
import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export interface DocsNavGroup {
label: string;
items: { label: string; href: string; badge?: string }[];
}
export interface DocsSection {
id: string;
title: string;
level?: 2 | 3;
content: ReactNode;
}
export interface DocsPageLink {
label: string;
href: string;
}
export interface DocsLayoutProps {
product?: string;
nav: DocsNavGroup[];
currentHref: string;
breadcrumbs?: string[];
title: string;
lead?: string;
meta?: string;
sections: DocsSection[];
prev?: DocsPageLink;
next?: DocsPageLink;
onNavigate?: (href: string) => void;
onSearch?: () => void;
className?: string;
}
const ease = [0.23, 1, 0.32, 1] as const;
const CALLOUT = {
note: { icon: InfoIcon, box: "border-info/25 bg-info/6 dark:bg-info/10", icon_: "text-info-foreground" },
tip: { icon: LightbulbIcon, box: "border-success/25 bg-success/6 dark:bg-success/10", icon_: "text-success-foreground" },
warning: { icon: TriangleAlertIcon, box: "border-warning/30 bg-warning/8 dark:bg-warning/10", icon_: "text-warning-foreground" },
};
export function DocsCallout({ tone = "note", title, children }: { tone?: keyof typeof CALLOUT; title?: string; children: ReactNode }) {
const c = CALLOUT[tone];
return (
{title &&
{title}
}
{children}
);
}
const KEYWORDS = new Set([
"const", "let", "var", "await", "async", "function", "return", "import", "from", "export", "if", "else", "new", "throw", "true", "false", "null",
]);
function highlight(line: string, lang: string) {
const out: ReactNode[] = [];
const re = /(\/\/.*$|#.*$)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b\d+\b)|(\s-{1,2}[A-Za-z-]+)|([A-Za-z_$][\w$]*)(?=\()|([A-Za-z_$][\w$]*)/g;
let last = 0;
let m: RegExpExecArray | null;
let k = 0;
while ((m = re.exec(line))) {
if (m.index > last) out.push(line.slice(last, m.index));
const [text, comment, str, num, flag, fn, word] = m;
let cls = "";
if (comment && (lang !== "bash" || comment.startsWith("#"))) cls = "text-muted-foreground italic";
else if (str) cls = "text-emerald-700 dark:text-emerald-300";
else if (num) cls = "text-amber-700 dark:text-amber-300";
else if (flag && lang === "bash") cls = "text-violet-700 dark:text-violet-300";
else if (fn) cls = "text-sky-700 dark:text-sky-300";
else if (word && KEYWORDS.has(word)) cls = "text-violet-700 dark:text-violet-300";
else if (word && lang === "bash" && m.index === 0) cls = "text-sky-700 dark:text-sky-300";
out.push(cls ? {text} : text);
last = m.index + text.length;
}
if (last < line.length) out.push(line.slice(last));
return out;
}
export function DocsCode({ tabs, caption }: { tabs: { label: string; lang: string; code: string }[]; caption?: string }) {
const reduce = useReducedMotion();
const layoutId = useId();
const [tab, setTab] = useState(0);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const t = setTimeout(() => setCopied(false), 1500);
return () => clearTimeout(t);
}, [copied]);
const current = tabs[tab];
const copy = async () => {
try {
await navigator.clipboard.writeText(current.code);
} catch {}
setCopied(true);
};
return (
1 ? "tablist" : undefined}>
{tabs.length > 1 ? (
tabs.map((t, i) => (
))
) : (
{caption ?? current.label}
)}
{current.code.split("\n").map((line, i) => (
{highlight(line, current.lang)}
))}
);
}
function Sidebar({
nav,
currentHref,
onNavigate,
onSearch,
product,
}: {
nav: DocsNavGroup[];
currentHref: string;
onNavigate?: (href: string) => void;
onSearch?: () => void;
product: string;
}) {
return (
);
}
export function DocsLayout({
product = "Relay",
nav,
currentHref,
breadcrumbs = [],
title,
lead,
meta,
sections,
prev,
next,
onNavigate,
onSearch,
className,
}: DocsLayoutProps) {
const reduce = useReducedMotion();
const uid = useId();
const scrollRef = useRef(null);
const [active, setActive] = useState(sections[0]?.id);
const [menuOpen, setMenuOpen] = useState(false);
const [tocOpen, setTocOpen] = useState(false);
const [feedback, setFeedback] = useState<"yes" | "no" | null>(null);
const { scrollYProgress } = useScroll({ container: scrollRef });
const sync = useCallback(() => {
const root = scrollRef.current;
if (!root) return;
const top = root.getBoundingClientRect().top;
let current = sections[0]?.id;
for (const s of sections) {
const el = root.querySelector(`[data-docs-id="${s.id}"]`);
if (el && el.getBoundingClientRect().top - top <= 96) current = s.id;
}
if (root.scrollTop + root.clientHeight >= root.scrollHeight - 4) current = sections[sections.length - 1]?.id;
setActive(current);
}, [sections]);
useEffect(() => {
sync();
}, [sync]);
const jump = (id: string) => {
const root = scrollRef.current;
const el = root?.querySelector(`[data-docs-id="${id}"]`);
if (!root || !el) return;
const y = el.getBoundingClientRect().top - root.getBoundingClientRect().top + root.scrollTop - 24;
root.scrollTo({ top: y, behavior: reduce ? "auto" : "smooth" });
setTocOpen(false);
};
const activeIndex = Math.max(0, sections.findIndex((s) => s.id === active));
return (
{product}
Docs
{breadcrumbs.length > 0 && (
{breadcrumbs.map((b, i) => (
-
{i > 0 && }
{b}
))}
)}
{title}
{lead && {lead}
}
{meta && {meta}
}
{tocOpen && (
{sections.map((s) => (
-
))}
)}
{sections.map((s) => {
const H = s.level === 3 ? "h3" : "h2";
return (
);
})}
{feedback ? (
{feedback === "yes" ? "Thanks! Glad it helped." : "Thanks. We’ll use this to improve the page."}
) : (
Was this page helpful?
)}
{(prev || next) && (
)}
{menuOpen && (
<>
setMenuOpen(false)}
transition={{ duration: 0.2 }}
type="button"
/>
e.key === "Escape" && setMenuOpen(false)}
role="dialog"
transition={{ duration: reduce ? 0 : 0.26, ease }}
>
{product} Docs
{
setMenuOpen(false);
onNavigate?.(href);
}}
onSearch={() => {
setMenuOpen(false);
onSearch?.();
}}
product={product}
/>
>
)}
);
}