feat(docs): add docs and restructure
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
|
||||
function ArrowIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m11.5 6.5 3 3.5m0 0-3 3.5m3-3.5h-9"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
primary:
|
||||
"rounded-full bg-zinc-900 py-1 px-3 text-white hover:bg-zinc-700 dark:bg-emerald-400/10 dark:text-emerald-400 dark:ring-1 dark:ring-inset dark:ring-emerald-400/20 dark:hover:bg-emerald-400/10 dark:hover:text-emerald-300 dark:hover:ring-emerald-300",
|
||||
secondary:
|
||||
"rounded-full bg-zinc-100 py-1 px-3 text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800/40 dark:text-zinc-400 dark:ring-1 dark:ring-inset dark:ring-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-300",
|
||||
filled:
|
||||
"rounded-full bg-zinc-900 py-1 px-3 text-white hover:bg-zinc-700 dark:bg-emerald-500 dark:text-white dark:hover:bg-emerald-400",
|
||||
outline:
|
||||
"rounded-full py-1 px-3 text-zinc-700 ring-1 ring-inset ring-zinc-900/10 hover:bg-zinc-900/2.5 hover:text-zinc-900 dark:text-zinc-400 dark:ring-white/10 dark:hover:bg-white/5 dark:hover:text-white",
|
||||
text: "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-500",
|
||||
};
|
||||
|
||||
type ButtonProps = {
|
||||
variant?: keyof typeof variantStyles;
|
||||
arrow?: "left" | "right";
|
||||
} & (
|
||||
| React.ComponentPropsWithoutRef<typeof Link>
|
||||
| (React.ComponentPropsWithoutRef<"button"> & { href?: undefined })
|
||||
);
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
className,
|
||||
children,
|
||||
arrow,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
className = clsx(
|
||||
"inline-flex gap-0.5 justify-center overflow-hidden text-sm font-medium transition items-center ",
|
||||
variantStyles[variant],
|
||||
className
|
||||
);
|
||||
|
||||
const arrowIcon = (
|
||||
<ArrowIcon
|
||||
className={clsx(
|
||||
"h-5 w-5",
|
||||
variant === "text" && "relative top-px",
|
||||
arrow === "left" && "-ml-1 rotate-180",
|
||||
arrow === "right" && "-mr-1"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
{arrow === "left" && arrowIcon}
|
||||
{children}
|
||||
{arrow === "right" && arrowIcon}
|
||||
</>
|
||||
);
|
||||
|
||||
if (typeof props.href === "undefined") {
|
||||
return (
|
||||
<button className={className} {...props}>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link className={className} {...props}>
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
"use client";
|
||||
|
||||
import { Tag } from "@/components/docs/Tag";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
Children,
|
||||
createContext,
|
||||
isValidElement,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { create } from "zustand";
|
||||
|
||||
const languageNames: Record<string, string> = {
|
||||
js: "JavaScript",
|
||||
ts: "TypeScript",
|
||||
javascript: "JavaScript",
|
||||
typescript: "TypeScript",
|
||||
php: "PHP",
|
||||
python: "Python",
|
||||
ruby: "Ruby",
|
||||
go: "Go",
|
||||
};
|
||||
|
||||
function getPanelTitle({
|
||||
title,
|
||||
language,
|
||||
}: {
|
||||
title?: string;
|
||||
language?: string;
|
||||
}) {
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
if (language && language in languageNames) {
|
||||
return languageNames[language];
|
||||
}
|
||||
return "Code";
|
||||
}
|
||||
|
||||
function ClipboardIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeWidth="0"
|
||||
d="M5.5 13.5v-5a2 2 0 0 1 2-2l.447-.894A2 2 0 0 1 9.737 4.5h.527a2 2 0 0 1 1.789 1.106l.447.894a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinejoin="round"
|
||||
d="M12.5 6.5a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2m5 0-.447-.894a2 2 0 0 0-1.79-1.106h-.527a2 2 0 0 0-1.789 1.106L7.5 6.5m5 0-1 1h-3l-1-1"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ code }: { code: string }) {
|
||||
const [copyCount, setCopyCount] = useState(0);
|
||||
const copied = copyCount > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (copyCount > 0) {
|
||||
const timeout = setTimeout(() => setCopyCount(0), 1000);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}
|
||||
}, [copyCount]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
"group/button absolute right-4 top-3.5 overflow-hidden rounded-full py-1 pl-2 pr-3 text-2xs font-medium opacity-0 backdrop-blur transition focus:opacity-100 group-hover:opacity-100",
|
||||
copied
|
||||
? "bg-emerald-400/10 ring-1 ring-inset ring-emerald-400/20"
|
||||
: "bg-white/5 hover:bg-white/7.5 dark:bg-white/2.5 dark:hover:bg-white/5"
|
||||
)}
|
||||
onClick={() => {
|
||||
window.navigator.clipboard.writeText(code).then(() => {
|
||||
setCopyCount((count) => count + 1);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden={copied}
|
||||
className={clsx(
|
||||
"pointer-events-none flex items-center gap-0.5 text-zinc-400 transition duration-300",
|
||||
copied && "-translate-y-1.5 opacity-0"
|
||||
)}
|
||||
>
|
||||
<ClipboardIcon className="h-5 w-5 fill-zinc-500/20 stroke-zinc-500 transition-colors group-hover/button:stroke-zinc-400" />
|
||||
Copy
|
||||
</span>
|
||||
<span
|
||||
aria-hidden={!copied}
|
||||
className={clsx(
|
||||
"pointer-events-none absolute inset-0 flex items-center justify-center text-emerald-400 transition duration-300",
|
||||
!copied && "translate-y-1.5 opacity-0"
|
||||
)}
|
||||
>
|
||||
Copied!
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CodePanelHeader({ tag, label }: { tag?: string; label?: string }) {
|
||||
if (!tag && !label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-9 items-center gap-2 border-y border-b-white/7.5 border-t-transparent bg-white/2.5 bg-zinc-900 px-4 dark:border-b-white/5 dark:bg-white/1">
|
||||
{tag && (
|
||||
<div className="dark flex">
|
||||
<Tag variant="small">{tag}</Tag>
|
||||
</div>
|
||||
)}
|
||||
{tag && label && (
|
||||
<span className="h-0.5 w-0.5 rounded-full bg-zinc-500" />
|
||||
)}
|
||||
{label && (
|
||||
<span className="font-mono text-xs text-zinc-400">{label}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodePanel({
|
||||
children,
|
||||
tag,
|
||||
label,
|
||||
code,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
tag?: string;
|
||||
label?: string;
|
||||
code?: string;
|
||||
}) {
|
||||
const child = Children.only(children);
|
||||
|
||||
if (isValidElement(child)) {
|
||||
tag = child.props.tag ?? tag;
|
||||
label = child.props.label ?? label;
|
||||
code = child.props.code ?? code;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error(
|
||||
"`CodePanel` requires a `code` prop, or a child with a `code` prop."
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group dark:bg-white/2.5">
|
||||
<CodePanelHeader tag={tag} label={label} />
|
||||
<div className="relative">
|
||||
<pre className="overflow-x-auto p-4 text-xs text-white">{children}</pre>
|
||||
<CopyButton code={code} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeGroupHeader({
|
||||
title,
|
||||
children,
|
||||
selectedIndex,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
selectedIndex: number;
|
||||
}) {
|
||||
const hasTabs = Children.count(children) > 1;
|
||||
|
||||
if (!title && !hasTabs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(theme(spacing.12)+1px)] flex-wrap items-start gap-x-4 border-b border-zinc-700 bg-zinc-800 px-4 dark:border-zinc-800 dark:bg-transparent">
|
||||
{title && (
|
||||
<h3 className="mr-auto pt-3 text-xs font-semibold text-white">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{hasTabs && (
|
||||
<Tab.List className="-mb-px flex gap-4 text-xs font-medium">
|
||||
{Children.map(children, (child, childIndex) => (
|
||||
<Tab
|
||||
className={clsx(
|
||||
"border-b py-3 transition ui-not-focus-visible:outline-none",
|
||||
childIndex === selectedIndex
|
||||
? "border-emerald-500 text-emerald-400"
|
||||
: "border-transparent text-zinc-400 hover:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{getPanelTitle(isValidElement(child) ? child.props : {})}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeGroupPanels({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof CodePanel>) {
|
||||
const hasTabs = Children.count(children) > 1;
|
||||
|
||||
if (hasTabs) {
|
||||
return (
|
||||
<Tab.Panels>
|
||||
{Children.map(children, (child) => (
|
||||
<Tab.Panel>
|
||||
<CodePanel {...props}>{child}</CodePanel>
|
||||
</Tab.Panel>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
);
|
||||
}
|
||||
|
||||
return <CodePanel {...props}>{children}</CodePanel>;
|
||||
}
|
||||
|
||||
function usePreventLayoutShift() {
|
||||
const positionRef = useRef<HTMLElement>(null);
|
||||
const rafRef = useRef<number>();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typeof rafRef.current !== "undefined") {
|
||||
window.cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
positionRef,
|
||||
preventLayoutShift(callback: () => void) {
|
||||
if (!positionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialTop = positionRef.current.getBoundingClientRect().top;
|
||||
|
||||
callback();
|
||||
|
||||
rafRef.current = window.requestAnimationFrame(() => {
|
||||
const newTop =
|
||||
positionRef.current?.getBoundingClientRect().top ?? initialTop;
|
||||
window.scrollBy(0, newTop - initialTop);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const usePreferredLanguageStore = create<{
|
||||
preferredLanguages: Array<string>;
|
||||
addPreferredLanguage: (language: string) => void;
|
||||
}>()((set) => ({
|
||||
preferredLanguages: [],
|
||||
addPreferredLanguage: (language) =>
|
||||
set((state) => ({
|
||||
preferredLanguages: [
|
||||
...state.preferredLanguages.filter(
|
||||
(preferredLanguage) => preferredLanguage !== language
|
||||
),
|
||||
language,
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
function useTabGroupProps(availableLanguages: Array<string>) {
|
||||
const { preferredLanguages, addPreferredLanguage } =
|
||||
usePreferredLanguageStore();
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const activeLanguage = [...availableLanguages].sort(
|
||||
(a, z) => preferredLanguages.indexOf(z) - preferredLanguages.indexOf(a)
|
||||
)[0];
|
||||
const languageIndex = availableLanguages.indexOf(activeLanguage);
|
||||
const newSelectedIndex = languageIndex === -1 ? selectedIndex : languageIndex;
|
||||
if (newSelectedIndex !== selectedIndex) {
|
||||
setSelectedIndex(newSelectedIndex);
|
||||
}
|
||||
|
||||
const { positionRef, preventLayoutShift } = usePreventLayoutShift();
|
||||
|
||||
return {
|
||||
as: "div" as const,
|
||||
ref: positionRef,
|
||||
selectedIndex,
|
||||
onChange: (newSelectedIndex: number) => {
|
||||
preventLayoutShift(() =>
|
||||
addPreferredLanguage(availableLanguages[newSelectedIndex])
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const CodeGroupContext = createContext(false);
|
||||
|
||||
export function CodeGroup({
|
||||
children,
|
||||
title,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof CodeGroupPanels> & { title: string }) {
|
||||
const languages =
|
||||
Children.map(children, (child) =>
|
||||
getPanelTitle(isValidElement(child) ? child.props : {})
|
||||
) ?? [];
|
||||
const tabGroupProps = useTabGroupProps(languages);
|
||||
const hasTabs = Children.count(children) > 1;
|
||||
|
||||
const containerClassName =
|
||||
"my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10";
|
||||
const header = (
|
||||
<CodeGroupHeader title={title} selectedIndex={tabGroupProps.selectedIndex}>
|
||||
{children}
|
||||
</CodeGroupHeader>
|
||||
);
|
||||
const panels = <CodeGroupPanels {...props}>{children}</CodeGroupPanels>;
|
||||
|
||||
return (
|
||||
<CodeGroupContext.Provider value={true}>
|
||||
{hasTabs ? (
|
||||
<Tab.Group {...tabGroupProps} className={containerClassName}>
|
||||
<div className="not-prose">
|
||||
{header}
|
||||
{panels}
|
||||
</div>
|
||||
</Tab.Group>
|
||||
) : (
|
||||
<div className={containerClassName}>
|
||||
<div className="not-prose">
|
||||
{header}
|
||||
{panels}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CodeGroupContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function Code({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"code">) {
|
||||
const isGrouped = useContext(CodeGroupContext);
|
||||
|
||||
if (isGrouped) {
|
||||
if (typeof children !== "string") {
|
||||
throw new Error(
|
||||
"`Code` children must be a string when nested inside a `CodeGroup`."
|
||||
);
|
||||
}
|
||||
return <code {...props} dangerouslySetInnerHTML={{ __html: children }} />;
|
||||
}
|
||||
|
||||
return <code {...props}>{children}</code>;
|
||||
}
|
||||
|
||||
export function Pre({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof CodeGroup>) {
|
||||
const isGrouped = useContext(CodeGroupContext);
|
||||
|
||||
if (isGrouped) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return <CodeGroup {...props}>{children}</CodeGroup>;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef, Fragment, useState } from 'react'
|
||||
import { Transition } from '@headlessui/react'
|
||||
|
||||
function CheckIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<circle cx="10" cy="10" r="10" strokeWidth="0" />
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="m6.75 10.813 2.438 2.437c1.218-4.469 4.062-6.5 4.062-6.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedbackButton(
|
||||
props: Omit<React.ComponentPropsWithoutRef<'button'>, 'type' | 'className'>,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
className="px-3 text-sm font-medium text-zinc-600 transition hover:bg-zinc-900/2.5 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const FeedbackForm = forwardRef<
|
||||
React.ElementRef<'form'>,
|
||||
Pick<React.ComponentPropsWithoutRef<'form'>, 'onSubmit'>
|
||||
>(function FeedbackForm({ onSubmit }, ref) {
|
||||
return (
|
||||
<form
|
||||
ref={ref}
|
||||
onSubmit={onSubmit}
|
||||
className="absolute inset-0 flex items-center justify-center gap-6 md:justify-start"
|
||||
>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Was this page helpful?
|
||||
</p>
|
||||
<div className="group grid h-8 grid-cols-[1fr,1px,1fr] overflow-hidden rounded-full border border-zinc-900/10 dark:border-white/10">
|
||||
<FeedbackButton data-response="yes">Yes</FeedbackButton>
|
||||
<div className="bg-zinc-900/10 dark:bg-white/10" />
|
||||
<FeedbackButton data-response="no">No</FeedbackButton>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
})
|
||||
|
||||
const FeedbackThanks = forwardRef<React.ElementRef<'div'>>(
|
||||
function FeedbackThanks(_props, ref) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute inset-0 flex justify-center md:justify-start"
|
||||
>
|
||||
<div className="flex items-center gap-3 rounded-full bg-emerald-50/50 py-1 pl-1.5 pr-3 text-sm text-emerald-900 ring-1 ring-inset ring-emerald-500/20 dark:bg-emerald-500/5 dark:text-emerald-200 dark:ring-emerald-500/30">
|
||||
<CheckIcon className="h-5 w-5 flex-none fill-emerald-500 stroke-white dark:fill-emerald-200/20 dark:stroke-emerald-200" />
|
||||
Thanks for your feedback!
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export function Feedback() {
|
||||
let [submitted, setSubmitted] = useState(false)
|
||||
|
||||
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
// event.nativeEvent.submitter.dataset.response
|
||||
// => "yes" or "no"
|
||||
|
||||
setSubmitted(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-8">
|
||||
<Transition
|
||||
show={!submitted}
|
||||
as={Fragment}
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
leave="pointer-events-none duration-300"
|
||||
>
|
||||
<FeedbackForm onSubmit={onSubmit} />
|
||||
</Transition>
|
||||
<Transition
|
||||
show={submitted}
|
||||
as={Fragment}
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
enter="delay-150 duration-300"
|
||||
>
|
||||
<FeedbackThanks />
|
||||
</Transition>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/docs/Button";
|
||||
import { navigation } from "@/components/docs/Navigation";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
function PageLink({
|
||||
label,
|
||||
page,
|
||||
previous = false,
|
||||
}: {
|
||||
label: string;
|
||||
page: { href: string; title: string };
|
||||
previous?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
href={page.href}
|
||||
aria-label={`${label}: ${page.title}`}
|
||||
variant="secondary"
|
||||
arrow={previous ? "left" : "right"}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
<Link
|
||||
href={page.href}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="text-base font-semibold text-zinc-900 transition hover:text-zinc-600 dark:text-white dark:hover:text-zinc-300"
|
||||
>
|
||||
{page.title}
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PageNavigation() {
|
||||
const pathname = usePathname();
|
||||
const allPages = navigation.flatMap((group) => group.links);
|
||||
const currentPageIndex = allPages.findIndex((page) => page.href === pathname);
|
||||
|
||||
if (currentPageIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousPage = allPages[currentPageIndex - 1];
|
||||
const nextPage = allPages[currentPageIndex + 1];
|
||||
|
||||
if (!previousPage && !nextPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
{previousPage && (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<PageLink label="Previous" page={previousPage} previous />
|
||||
</div>
|
||||
)}
|
||||
{nextPage && (
|
||||
<div className="ml-auto flex flex-col items-end gap-3">
|
||||
<PageLink label="Next" page={nextPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TwitterIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path d="M16.712 6.652c.01.146.01.29.01.436 0 4.449-3.267 9.579-9.242 9.579v-.003a8.963 8.963 0 0 1-4.98-1.509 6.379 6.379 0 0 0 4.807-1.396c-1.39-.027-2.608-.966-3.035-2.337.487.097.99.077 1.467-.059-1.514-.316-2.606-1.696-2.606-3.3v-.041c.45.26.956.404 1.475.42C3.18 7.454 2.74 5.486 3.602 3.947c1.65 2.104 4.083 3.382 6.695 3.517a3.446 3.446 0 0 1 .94-3.217 3.172 3.172 0 0 1 4.596.148 6.38 6.38 0 0 0 2.063-.817 3.357 3.357 0 0 1-1.428 1.861 6.283 6.283 0 0 0 1.865-.53 6.735 6.735 0 0 1-1.62 1.744Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10 1.667c-4.605 0-8.334 3.823-8.334 8.544 0 3.78 2.385 6.974 5.698 8.106.417.075.573-.182.573-.406 0-.203-.011-.875-.011-1.592-2.093.397-2.635-.522-2.802-1.002-.094-.246-.5-1.005-.854-1.207-.291-.16-.708-.556-.01-.567.656-.01 1.124.62 1.281.876.75 1.292 1.948.93 2.427.705.073-.555.291-.93.531-1.143-1.854-.213-3.791-.95-3.791-4.218 0-.929.322-1.698.854-2.296-.083-.214-.375-1.09.083-2.265 0 0 .698-.224 2.292.876a7.576 7.576 0 0 1 2.083-.288c.709 0 1.417.096 2.084.288 1.593-1.11 2.291-.875 2.291-.875.459 1.174.167 2.05.084 2.263.53.599.854 1.357.854 2.297 0 3.278-1.948 4.005-3.802 4.219.302.266.563.78.563 1.58 0 1.143-.011 2.061-.011 2.35 0 .224.156.491.573.405a8.365 8.365 0 0 0 4.11-3.116 8.707 8.707 0 0 0 1.567-4.99c0-4.721-3.73-8.545-8.334-8.545Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DiscordIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path d="M16.238 4.515a14.842 14.842 0 0 0-3.664-1.136.055.055 0 0 0-.059.027 10.35 10.35 0 0 0-.456.938 13.702 13.702 0 0 0-4.115 0 9.479 9.479 0 0 0-.464-.938.058.058 0 0 0-.058-.027c-1.266.218-2.497.6-3.664 1.136a.052.052 0 0 0-.024.02C1.4 8.023.76 11.424 1.074 14.782a.062.062 0 0 0 .024.042 14.923 14.923 0 0 0 4.494 2.272.058.058 0 0 0 .064-.02c.346-.473.654-.972.92-1.496a.057.057 0 0 0-.032-.08 9.83 9.83 0 0 1-1.404-.669.058.058 0 0 1-.029-.046.058.058 0 0 1 .023-.05c.094-.07.189-.144.279-.218a.056.056 0 0 1 .058-.008c2.946 1.345 6.135 1.345 9.046 0a.056.056 0 0 1 .059.007c.09.074.184.149.28.22a.058.058 0 0 1 .023.049.059.059 0 0 1-.028.046 9.224 9.224 0 0 1-1.405.669.058.058 0 0 0-.033.033.056.056 0 0 0 .002.047c.27.523.58 1.022.92 1.495a.056.056 0 0 0 .062.021 14.878 14.878 0 0 0 4.502-2.272.055.055 0 0 0 .016-.018.056.056 0 0 0 .008-.023c.375-3.883-.63-7.256-2.662-10.246a.046.046 0 0 0-.023-.021Zm-9.223 8.221c-.887 0-1.618-.814-1.618-1.814s.717-1.814 1.618-1.814c.908 0 1.632.821 1.618 1.814 0 1-.717 1.814-1.618 1.814Zm5.981 0c-.887 0-1.618-.814-1.618-1.814s.717-1.814 1.618-1.814c.908 0 1.632.821 1.618 1.814 0 1-.71 1.814-1.618 1.814Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SocialLink({
|
||||
href,
|
||||
icon: Icon,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link href={href} className="group">
|
||||
<span className="sr-only">{children}</span>
|
||||
<Icon className="h-5 w-5 fill-zinc-700 transition group-hover:fill-zinc-900 dark:group-hover:fill-zinc-500" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SmallPrint() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-between gap-5 border-t border-zinc-900/5 pt-8 dark:border-white/5 sm:flex-row">
|
||||
<p className="text-xs text-zinc-600 dark:text-zinc-400">
|
||||
© Copyright {new Date().getFullYear()}. All rights reserved.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<SocialLink href="#" icon={TwitterIcon}>
|
||||
Follow us on Twitter
|
||||
</SocialLink>
|
||||
<SocialLink href="#" icon={GitHubIcon}>
|
||||
Follow us on GitHub
|
||||
</SocialLink>
|
||||
<SocialLink href="#" icon={DiscordIcon}>
|
||||
Join our Discord server
|
||||
</SocialLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="mx-auto w-full max-w-2xl space-y-10 pb-16 lg:max-w-5xl">
|
||||
<PageNavigation />
|
||||
<SmallPrint />
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useId } from "react";
|
||||
|
||||
export function GridPattern({
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
squares,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"svg"> & {
|
||||
width: number;
|
||||
height: number;
|
||||
x: string | number;
|
||||
y: string | number;
|
||||
squares: Array<[x: number, y: number]>;
|
||||
}) {
|
||||
const patternId = useId();
|
||||
|
||||
return (
|
||||
<svg aria-hidden="true" {...props}>
|
||||
<defs>
|
||||
<pattern
|
||||
id={patternId}
|
||||
width={width}
|
||||
height={height}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={x}
|
||||
y={y}
|
||||
>
|
||||
<path d={`M.5 ${height}V.5H${width}`} fill="none" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
strokeWidth={0}
|
||||
fill={`url(#${patternId})`}
|
||||
/>
|
||||
{squares && (
|
||||
<svg x={x} y={y} className="overflow-visible">
|
||||
{squares.map(([x, y]) => (
|
||||
<rect
|
||||
strokeWidth="0"
|
||||
key={`${x}-${y}`}
|
||||
width={width + 1}
|
||||
height={height + 1}
|
||||
x={x * width}
|
||||
y={y * height}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Button } from "@/components/docs/Button";
|
||||
import { Heading } from "@/components/docs/Heading";
|
||||
|
||||
const guides = [
|
||||
{
|
||||
href: "/authentication",
|
||||
name: "Authentication",
|
||||
description: "Learn how to authenticate your API requests.",
|
||||
},
|
||||
{
|
||||
href: "/pagination",
|
||||
name: "Pagination",
|
||||
description: "Understand how to work with paginated responses.",
|
||||
},
|
||||
{
|
||||
href: "/errors",
|
||||
name: "Errors",
|
||||
description:
|
||||
"Read about the different types of errors returned by the API.",
|
||||
},
|
||||
{
|
||||
href: "/webhooks",
|
||||
name: "Webhooks",
|
||||
description:
|
||||
"Learn how to programmatically configure webhooks for your app.",
|
||||
},
|
||||
];
|
||||
|
||||
export function Guides() {
|
||||
return (
|
||||
<div className="my-16 xl:max-w-none">
|
||||
<Heading level={2} id="guides">
|
||||
Guides
|
||||
</Heading>
|
||||
<div className="not-prose mt-4 grid grid-cols-1 gap-8 border-t border-zinc-900/5 pt-10 dark:border-white/5 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{guides.map((guide) => (
|
||||
<div key={guide.href}>
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-white">
|
||||
{guide.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{guide.description}
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
<Button href={guide.href} variant="text" arrow="right">
|
||||
Read more
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
MobileNavigation,
|
||||
useIsInsideMobileNavigation,
|
||||
} from "@/components/docs/MobileNavigation";
|
||||
import { useMobileNavigationStore } from "@/components/docs/MobileNavigation";
|
||||
import { MobileSearch, Search } from "@/components/docs/Search";
|
||||
import { ThemeToggle } from "@/components/docs/ThemeToggle";
|
||||
import clsx from "clsx";
|
||||
import { motion, useScroll, useTransform } from "framer-motion";
|
||||
import meta from "next-gen/config";
|
||||
import Link from "next/link";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
function TopLevelNavItem({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={href}
|
||||
className="text-sm leading-5 text-zinc-600 transition hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export const Header = forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
{ className?: string }
|
||||
>(function Header({ className }, ref) {
|
||||
const { isOpen: mobileNavIsOpen } = useMobileNavigationStore();
|
||||
const isInsideMobileNavigation = useIsInsideMobileNavigation();
|
||||
|
||||
const { scrollY } = useScroll();
|
||||
const bgOpacityLight = useTransform(scrollY, [0, 72], [0.5, 0.9]);
|
||||
const bgOpacityDark = useTransform(scrollY, [0, 72], [0.2, 0.8]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
className,
|
||||
"fixed inset-x-0 top-0 z-50 flex h-14 items-center justify-between gap-12 px-4 transition sm:px-6 lg:left-72 lg:z-30 lg:px-8 xl:left-80",
|
||||
!isInsideMobileNavigation &&
|
||||
"backdrop-blur-sm dark:backdrop-blur lg:left-72 xl:left-80",
|
||||
isInsideMobileNavigation
|
||||
? "bg-white dark:bg-zinc-900"
|
||||
: "bg-white/[var(--bg-opacity-light)] dark:bg-zinc-900/[var(--bg-opacity-dark)]"
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--bg-opacity-light": bgOpacityLight,
|
||||
"--bg-opacity-dark": bgOpacityDark,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute inset-x-0 top-full h-px transition",
|
||||
(isInsideMobileNavigation || !mobileNavIsOpen) &&
|
||||
"bg-zinc-900/7.5 dark:bg-white/7.5"
|
||||
)}
|
||||
/>
|
||||
<Search />
|
||||
<div className="flex items-center gap-5 lg:hidden">
|
||||
<MobileNavigation />
|
||||
{/* <Link href="/" aria-label="Home">
|
||||
<Logo className="h-6" />
|
||||
</Link> */}
|
||||
<a className="font-bold text-md md:text-lg hover:underline" href="/">
|
||||
{meta.name}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-5">
|
||||
{/* <nav className="hidden md:block">
|
||||
<ul role="list" className="flex items-center gap-8">
|
||||
<TopLevelNavItem href="/">API</TopLevelNavItem>
|
||||
<TopLevelNavItem href="#">Documentation</TopLevelNavItem>
|
||||
<TopLevelNavItem href="#">Support</TopLevelNavItem>
|
||||
</ul>
|
||||
</nav> */}
|
||||
<div className="hidden md:block md:h-5 md:w-px md:bg-zinc-900/10 md:dark:bg-white/15" />
|
||||
<div className="flex gap-4">
|
||||
<MobileSearch />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{/* <div className="hidden min-[416px]:contents">
|
||||
<Button href="#">Sign in</Button>
|
||||
</div> */}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useSectionStore } from "@/components/docs/SectionProvider";
|
||||
import { Tag } from "@/components/docs/Tag";
|
||||
import { remToPx } from "@/lib/remToPx";
|
||||
import { useInView } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
function AnchorIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="m6.5 11.5-.964-.964a3.535 3.535 0 1 1 5-5l.964.964m2 2 .964.964a3.536 3.536 0 0 1-5 5L8.5 13.5m0-5 3 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Eyebrow({ tag, label }: { tag?: string; label?: string }) {
|
||||
if (!tag && !label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-3">
|
||||
{tag && <Tag>{tag}</Tag>}
|
||||
{tag && label && (
|
||||
<span className="h-0.5 w-0.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
)}
|
||||
{label && (
|
||||
<span className="font-mono text-xs text-zinc-400">{label}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Anchor({
|
||||
id,
|
||||
inView,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
inView: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`#${id}`}
|
||||
className="group text-inherit no-underline hover:text-inherit"
|
||||
>
|
||||
{inView && (
|
||||
<div className="absolute ml-[calc(-1*var(--width))] mt-1 hidden w-[var(--width)] opacity-0 transition [--width:calc(2.625rem+0.5px+50%-min(50%,calc(theme(maxWidth.lg)+theme(spacing.8))))] group-hover:opacity-100 group-focus:opacity-100 md:block lg:z-50 2xl:[--width:theme(spacing.10)]">
|
||||
<div className="group/anchor block h-5 w-5 rounded-lg bg-zinc-50 ring-1 ring-inset ring-zinc-300 transition hover:ring-zinc-500 dark:bg-zinc-800 dark:ring-zinc-700 dark:hover:bg-zinc-700 dark:hover:ring-zinc-600">
|
||||
<AnchorIcon className="h-5 w-5 stroke-zinc-500 transition dark:stroke-zinc-400 dark:group-hover/anchor:stroke-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function Heading<Level extends 2 | 3>({
|
||||
children,
|
||||
tag,
|
||||
label,
|
||||
level,
|
||||
anchor = true,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<`h${Level}`> & {
|
||||
id: string;
|
||||
tag?: string;
|
||||
label?: string;
|
||||
level?: Level;
|
||||
anchor?: boolean;
|
||||
}) {
|
||||
level = level ?? (2 as Level);
|
||||
const Component = `h${level}` as "h2" | "h3";
|
||||
const ref = useRef<HTMLHeadingElement>(null);
|
||||
const registerHeading = useSectionStore((s) => s.registerHeading);
|
||||
|
||||
const inView = useInView(ref, {
|
||||
margin: `${remToPx(-3.5)}px 0px 0px 0px`,
|
||||
amount: "all",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (level === 2) {
|
||||
registerHeading({ id: props.id, ref, offsetRem: tag || label ? 8 : 6 });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Eyebrow tag={tag} label={label} />
|
||||
<Component
|
||||
ref={ref}
|
||||
className={tag || label ? "mt-2 scroll-mt-32" : "scroll-mt-24"}
|
||||
{...props}
|
||||
>
|
||||
{anchor ? (
|
||||
<Anchor id={props.id} inView={inView}>
|
||||
{children}
|
||||
</Anchor>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Component>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { GridPattern } from "@/components/docs/GridPattern";
|
||||
|
||||
export function HeroPattern() {
|
||||
return (
|
||||
<div className="absolute inset-0 -z-10 mx-0 max-w-none overflow-hidden">
|
||||
<div className="absolute left-1/2 top-0 ml-[-38rem] h-[25rem] w-[81.25rem] dark:[mask-image:linear-gradient(white,transparent)]">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#36b49f] to-[#DBFF75] opacity-40 [mask-image:radial-gradient(farthest-side_at_top,white,transparent)] dark:from-[#36b49f]/30 dark:to-[#DBFF75]/30 dark:opacity-100">
|
||||
<GridPattern
|
||||
width={72}
|
||||
height={56}
|
||||
x={-12}
|
||||
y={4}
|
||||
squares={[
|
||||
[4, 3],
|
||||
[2, 1],
|
||||
[7, 3],
|
||||
[10, 6],
|
||||
]}
|
||||
className="absolute inset-x-0 inset-y-[-50%] h-[200%] w-full skew-y-[-18deg] fill-black/40 stroke-black/50 mix-blend-overlay dark:fill-white/2.5 dark:stroke-white/5"
|
||||
/>
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 1113 440"
|
||||
aria-hidden="true"
|
||||
className="absolute left-1/2 top-0 ml-[-19rem] w-[69.5625rem] fill-white blur-[26px] dark:hidden"
|
||||
>
|
||||
<path d="M.016 439.5s-9.5-300 434-300S882.516 20 882.516 20V0h230.004v439.5H.016Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { Footer } from "@/components/docs/Footer";
|
||||
import { Header } from "@/components/docs/Header";
|
||||
import { Navigation } from "@/components/docs/Navigation";
|
||||
import {
|
||||
type Section,
|
||||
SectionProvider,
|
||||
} from "@/components/docs/SectionProvider";
|
||||
import { motion } from "framer-motion";
|
||||
import meta from "next-gen/config";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export function Layout({
|
||||
children,
|
||||
allSections,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
allSections: Record<string, Array<Section>>;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<SectionProvider sections={allSections[pathname] ?? []}>
|
||||
<div className="h-full lg:ml-72 xl:ml-80">
|
||||
<motion.header
|
||||
layoutScroll
|
||||
className="contents lg:pointer-events-none lg:fixed lg:inset-0 lg:z-40 lg:flex"
|
||||
>
|
||||
<div className="contents lg:pointer-events-auto lg:block lg:w-72 lg:overflow-y-auto lg:border-r lg:border-zinc-900/10 lg:px-6 lg:pb-8 lg:pt-4 lg:dark:border-white/10 xl:w-80">
|
||||
<div className="hidden lg:flex">
|
||||
<Link
|
||||
href="/"
|
||||
className="font-bold text-md md:text-lg hover:underline"
|
||||
aria-label="Home"
|
||||
>
|
||||
{meta.name}
|
||||
</Link>
|
||||
</div>
|
||||
<Header />
|
||||
<Navigation className="hidden lg:mt-10 lg:block" />
|
||||
</div>
|
||||
</motion.header>
|
||||
<div className="relative flex h-full flex-col px-4 pt-14 sm:px-6 lg:px-8">
|
||||
<main className="flex-auto">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</SectionProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Button } from "@/components/docs/Button";
|
||||
import { Heading } from "@/components/docs/Heading";
|
||||
import logoGo from "@/images/logos/go.svg";
|
||||
import logoNode from "@/images/logos/node.svg";
|
||||
import logoPhp from "@/images/logos/php.svg";
|
||||
import logoPython from "@/images/logos/python.svg";
|
||||
import logoRuby from "@/images/logos/ruby.svg";
|
||||
import Image from "next/image";
|
||||
|
||||
const libraries = [
|
||||
{
|
||||
href: "#",
|
||||
name: "PHP",
|
||||
description:
|
||||
"A popular general-purpose scripting language that is especially suited to web development.",
|
||||
logo: logoPhp,
|
||||
},
|
||||
{
|
||||
href: "#",
|
||||
name: "Ruby",
|
||||
description:
|
||||
"A dynamic, open source programming language with a focus on simplicity and productivity.",
|
||||
logo: logoRuby,
|
||||
},
|
||||
{
|
||||
href: "#",
|
||||
name: "Node.js",
|
||||
description:
|
||||
"Node.js® is an open-source, cross-platform JavaScript runtime environment.",
|
||||
logo: logoNode,
|
||||
},
|
||||
{
|
||||
href: "#",
|
||||
name: "Python",
|
||||
description:
|
||||
"Python is a programming language that lets you work quickly and integrate systems more effectively.",
|
||||
logo: logoPython,
|
||||
},
|
||||
{
|
||||
href: "#",
|
||||
name: "Go",
|
||||
description:
|
||||
"An open-source programming language supported by Google with built-in concurrency.",
|
||||
logo: logoGo,
|
||||
},
|
||||
];
|
||||
|
||||
export function Libraries() {
|
||||
return (
|
||||
<div className="my-16 xl:max-w-none">
|
||||
<Heading level={2} id="official-libraries">
|
||||
Official libraries
|
||||
</Heading>
|
||||
<div className="not-prose mt-4 grid grid-cols-1 gap-x-6 gap-y-10 border-t border-zinc-900/5 pt-10 dark:border-white/5 sm:grid-cols-2 xl:max-w-none xl:grid-cols-3">
|
||||
{libraries.map((library) => (
|
||||
<div key={library.name} className="flex flex-row-reverse gap-6">
|
||||
<div className="flex-auto">
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-white">
|
||||
{library.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{library.description}
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
<Button href={library.href} variant="text" arrow="right">
|
||||
Read more
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
<Image
|
||||
src={library.logo}
|
||||
alt=""
|
||||
className="h-12 w-12"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function Logo(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 99 24" aria-hidden="true" {...props}>
|
||||
<path
|
||||
className="fill-emerald-400"
|
||||
d="M16 8a5 5 0 0 0-5-5H5a5 5 0 0 0-5 5v13.927a1 1 0 0 0 1.623.782l3.684-2.93a4 4 0 0 1 2.49-.87H11a5 5 0 0 0 5-5V8Z"
|
||||
/>
|
||||
<path
|
||||
className="fill-zinc-900 dark:fill-white"
|
||||
d="M26.538 18h2.654v-3.999h2.576c2.672 0 4.456-1.723 4.456-4.333V9.65c0-2.61-1.784-4.333-4.456-4.333h-5.23V18Zm4.58-10.582c1.52 0 2.416.8 2.416 2.241v.018c0 1.441-.896 2.25-2.417 2.25h-1.925V7.418h1.925ZM38.051 18h2.566v-5.414c0-1.371.923-2.206 2.382-2.206.396 0 .791.061 1.178.15V8.287a3.843 3.843 0 0 0-.958-.123c-1.257 0-2.136.615-2.443 1.661h-.159V8.323h-2.566V18Zm11.55.202c2.979 0 4.772-1.88 4.772-5.036v-.018c0-3.128-1.82-5.036-4.773-5.036-2.953 0-4.772 1.916-4.772 5.036v.018c0 3.146 1.793 5.036 4.772 5.036Zm0-2.013c-1.372 0-2.145-1.116-2.145-3.023v-.018c0-1.89.782-3.023 2.144-3.023 1.354 0 2.145 1.134 2.145 3.023v.018c0 1.907-.782 3.023-2.145 3.023Zm10.52 1.846c.492 0 .967-.053 1.283-.114v-1.907a6.057 6.057 0 0 1-.755.044c-.87 0-1.24-.387-1.24-1.257v-4.544h1.995V8.323H59.41V6.012h-2.592v2.311h-1.495v1.934h1.495v5.133c0 1.88.949 2.645 3.304 2.645Zm7.287.167c2.98 0 4.772-1.88 4.772-5.036v-.018c0-3.128-1.82-5.036-4.772-5.036-2.954 0-4.773 1.916-4.773 5.036v.018c0 3.146 1.793 5.036 4.773 5.036Zm0-2.013c-1.372 0-2.145-1.116-2.145-3.023v-.018c0-1.89.782-3.023 2.145-3.023 1.353 0 2.144 1.134 2.144 3.023v.018c0 1.907-.782 3.023-2.144 3.023Zm10.767 2.013c2.522 0 4.034-1.353 4.297-3.463l.01-.053h-2.374l-.017.036c-.229.966-.853 1.467-1.908 1.467-1.37 0-2.135-1.08-2.135-3.04v-.018c0-1.934.755-3.006 2.135-3.006 1.099 0 1.74.615 1.908 1.556l.008.017h2.391v-.026c-.228-2.162-1.749-3.56-4.315-3.56-3.033 0-4.738 1.837-4.738 5.019v.017c0 3.217 1.714 5.054 4.738 5.054Zm10.257 0c2.98 0 4.772-1.88 4.772-5.036v-.018c0-3.128-1.82-5.036-4.772-5.036-2.953 0-4.773 1.916-4.773 5.036v.018c0 3.146 1.793 5.036 4.773 5.036Zm0-2.013c-1.371 0-2.145-1.116-2.145-3.023v-.018c0-1.89.782-3.023 2.145-3.023 1.353 0 2.144 1.134 2.144 3.023v.018c0 1.907-.782 3.023-2.144 3.023ZM95.025 18h2.566V4.623h-2.566V18Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { Header } from "@/components/docs/Header";
|
||||
import { Navigation } from "@/components/docs/Navigation";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
createContext,
|
||||
Fragment,
|
||||
Suspense,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { create } from "zustand";
|
||||
|
||||
function MenuIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 10 9"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M.5 1h9M.5 8h9M.5 4.5h9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 10 9"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="m1.5 1 7 7M8.5 1l-7 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const IsInsideMobileNavigationContext = createContext(false);
|
||||
|
||||
function MobileNavigationDialog({
|
||||
isOpen,
|
||||
close,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
close: () => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const initialPathname = useRef(pathname).current;
|
||||
const initialSearchParams = useRef(searchParams).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname !== initialPathname || searchParams !== initialSearchParams) {
|
||||
close();
|
||||
}
|
||||
}, [pathname, searchParams, close, initialPathname, initialSearchParams]);
|
||||
|
||||
function onClickDialog(event: React.MouseEvent<HTMLDivElement>) {
|
||||
if (!(event.target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = event.target.closest("a");
|
||||
if (
|
||||
link &&
|
||||
link.pathname + link.search + link.hash ===
|
||||
window.location.pathname + window.location.search + window.location.hash
|
||||
) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog
|
||||
onClickCapture={onClickDialog}
|
||||
onClose={close}
|
||||
className="fixed inset-0 z-50 lg:hidden"
|
||||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="duration-300 ease-out"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="duration-200 ease-in"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 top-14 bg-zinc-400/20 backdrop-blur-sm dark:bg-black/40" />
|
||||
</Transition.Child>
|
||||
|
||||
<Dialog.Panel>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="duration-300 ease-out"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="duration-200 ease-in"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Header />
|
||||
</Transition.Child>
|
||||
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="duration-500 ease-in-out"
|
||||
enterFrom="-translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="duration-500 ease-in-out"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="-translate-x-full"
|
||||
>
|
||||
<motion.div
|
||||
layoutScroll
|
||||
className="fixed bottom-0 left-0 top-14 w-full overflow-y-auto bg-white px-4 pb-4 pt-6 shadow-lg shadow-zinc-900/10 ring-1 ring-zinc-900/7.5 dark:bg-zinc-900 dark:ring-zinc-800 min-[416px]:max-w-sm sm:px-6 sm:pb-10"
|
||||
>
|
||||
<Navigation />
|
||||
</motion.div>
|
||||
</Transition.Child>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsInsideMobileNavigation() {
|
||||
return useContext(IsInsideMobileNavigationContext);
|
||||
}
|
||||
|
||||
export const useMobileNavigationStore = create<{
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
}>()((set) => ({
|
||||
isOpen: false,
|
||||
open: () => set({ isOpen: true }),
|
||||
close: () => set({ isOpen: false }),
|
||||
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
|
||||
}));
|
||||
|
||||
export function MobileNavigation() {
|
||||
const isInsideMobileNavigation = useIsInsideMobileNavigation();
|
||||
const { isOpen, toggle, close } = useMobileNavigationStore();
|
||||
const ToggleIcon = isOpen ? XIcon : MenuIcon;
|
||||
|
||||
return (
|
||||
<IsInsideMobileNavigationContext.Provider value={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md transition hover:bg-zinc-900/5 dark:hover:bg-white/5"
|
||||
aria-label="Toggle navigation"
|
||||
onClick={toggle}
|
||||
>
|
||||
<ToggleIcon className="w-2.5 stroke-zinc-900 dark:stroke-white" />
|
||||
</button>
|
||||
{!isInsideMobileNavigation && (
|
||||
<Suspense fallback={null}>
|
||||
<MobileNavigationDialog isOpen={isOpen} close={close} />
|
||||
</Suspense>
|
||||
)}
|
||||
</IsInsideMobileNavigationContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/docs/Button";
|
||||
import { useIsInsideMobileNavigation } from "@/components/docs/MobileNavigation";
|
||||
import { useSectionStore } from "@/components/docs/SectionProvider";
|
||||
import { Tag } from "@/components/docs/Tag";
|
||||
import { remToPx } from "@/lib/remToPx";
|
||||
import clsx from "clsx";
|
||||
import { AnimatePresence, motion, useIsPresent } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRef } from "react";
|
||||
|
||||
interface NavGroup {
|
||||
title: string;
|
||||
links: Array<{
|
||||
title: string;
|
||||
href: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function useInitialValue<T>(value: T, condition = true) {
|
||||
const initialValue = useRef(value).current;
|
||||
return condition ? initialValue : value;
|
||||
}
|
||||
|
||||
function TopLevelNavItem({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<li className="md:hidden">
|
||||
<Link
|
||||
href={href}
|
||||
className="block py-1 text-sm text-zinc-600 transition hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
href,
|
||||
children,
|
||||
tag,
|
||||
active = false,
|
||||
isAnchorLink = false,
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
tag?: string;
|
||||
active?: boolean;
|
||||
isAnchorLink?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={clsx(
|
||||
"flex justify-between gap-2 py-1 pr-3 text-sm transition",
|
||||
isAnchorLink ? "pl-7" : "pl-4",
|
||||
active
|
||||
? "text-zinc-900 dark:text-white"
|
||||
: "text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{children}</span>
|
||||
{tag && (
|
||||
<Tag variant="small" color="zinc">
|
||||
{tag}
|
||||
</Tag>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function VisibleSectionHighlight({
|
||||
group,
|
||||
pathname,
|
||||
}: {
|
||||
group: NavGroup;
|
||||
pathname: string;
|
||||
}) {
|
||||
const [sections, visibleSections] = useInitialValue(
|
||||
[
|
||||
useSectionStore((s) => s.sections),
|
||||
useSectionStore((s) => s.visibleSections),
|
||||
],
|
||||
useIsInsideMobileNavigation()
|
||||
);
|
||||
|
||||
const isPresent = useIsPresent();
|
||||
const firstVisibleSectionIndex = Math.max(
|
||||
0,
|
||||
[{ id: "_top" }, ...sections].findIndex(
|
||||
(section) => section.id === visibleSections[0]
|
||||
)
|
||||
);
|
||||
const itemHeight = remToPx(2);
|
||||
const height = isPresent
|
||||
? Math.max(1, visibleSections.length) * itemHeight
|
||||
: itemHeight;
|
||||
const top =
|
||||
group.links.findIndex((link) => link.href === pathname) * itemHeight +
|
||||
firstVisibleSectionIndex * itemHeight;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { delay: 0.2 } }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-x-0 top-0 bg-zinc-800/2.5 will-change-transform dark:bg-white/2.5"
|
||||
style={{ borderRadius: 8, height, top }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivePageMarker({
|
||||
group,
|
||||
pathname,
|
||||
}: {
|
||||
group: NavGroup;
|
||||
pathname: string;
|
||||
}) {
|
||||
const itemHeight = remToPx(2);
|
||||
const offset = remToPx(0.25);
|
||||
const activePageIndex = group.links.findIndex(
|
||||
(link) => link.href === pathname
|
||||
);
|
||||
const top = offset + activePageIndex * itemHeight;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
className="absolute left-2 h-6 w-px bg-emerald-500"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { delay: 0.2 } }}
|
||||
exit={{ opacity: 0 }}
|
||||
style={{ top }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationGroup({
|
||||
group,
|
||||
className,
|
||||
}: {
|
||||
group: NavGroup;
|
||||
className?: string;
|
||||
}) {
|
||||
// If this is the mobile navigation then we always render the initial
|
||||
// state, so that the state does not change during the close animation.
|
||||
// The state will still update when we re-open (re-render) the navigation.
|
||||
const isInsideMobileNavigation = useIsInsideMobileNavigation();
|
||||
const [pathname, sections] = useInitialValue(
|
||||
[usePathname(), useSectionStore((s) => s.sections)],
|
||||
isInsideMobileNavigation
|
||||
);
|
||||
|
||||
const isActiveGroup =
|
||||
group.links.findIndex((link) => link.href === pathname) !== -1;
|
||||
|
||||
return (
|
||||
<li className={clsx("relative mt-6", className)}>
|
||||
<motion.h2
|
||||
layout="position"
|
||||
className="text-xs font-semibold text-zinc-900 dark:text-white"
|
||||
>
|
||||
{group.title}
|
||||
</motion.h2>
|
||||
<div className="relative mt-3 pl-2">
|
||||
<AnimatePresence initial={!isInsideMobileNavigation}>
|
||||
{isActiveGroup && (
|
||||
<VisibleSectionHighlight group={group} pathname={pathname} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<motion.div
|
||||
layout
|
||||
className="absolute inset-y-0 left-2 w-px bg-zinc-900/10 dark:bg-white/5"
|
||||
/>
|
||||
<AnimatePresence initial={false}>
|
||||
{isActiveGroup && (
|
||||
<ActivePageMarker group={group} pathname={pathname} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<ul role="list" className="border-l border-transparent">
|
||||
{group.links.map((link) => (
|
||||
<motion.li key={link.href} layout="position" className="relative">
|
||||
<NavLink href={link.href} active={link.href === pathname}>
|
||||
{link.title}
|
||||
</NavLink>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{link.href === pathname && sections.length > 0 && (
|
||||
<motion.ul
|
||||
role="list"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
transition: { delay: 0.1 },
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: { duration: 0.15 },
|
||||
}}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<li key={section.id}>
|
||||
<NavLink
|
||||
href={`${link.href}#${section.id}`}
|
||||
tag={section.tag}
|
||||
isAnchorLink
|
||||
>
|
||||
{section.title}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export const navigation: Array<NavGroup> = [
|
||||
{
|
||||
title: "Guides",
|
||||
links: [
|
||||
{ title: "Introduction", href: "/docs" },
|
||||
{ title: "Installation", href: "/docs/install" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function Navigation(props: React.ComponentPropsWithoutRef<"nav">) {
|
||||
return (
|
||||
<nav {...props}>
|
||||
<ul role="list">
|
||||
<TopLevelNavItem href="/">API</TopLevelNavItem>
|
||||
<TopLevelNavItem href="#">Documentation</TopLevelNavItem>
|
||||
<TopLevelNavItem href="#">Support</TopLevelNavItem>
|
||||
{navigation.map((group, groupIndex) => (
|
||||
<NavigationGroup
|
||||
key={group.title}
|
||||
group={group}
|
||||
className={groupIndex === 0 ? "md:mt-0" : ""}
|
||||
/>
|
||||
))}
|
||||
<li className="sticky bottom-0 z-10 mt-6 min-[416px]:hidden">
|
||||
<Button href="#" variant="filled" className="w-full">
|
||||
Sign in
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
export function Prose<T extends React.ElementType = 'div'>({
|
||||
as,
|
||||
className,
|
||||
...props
|
||||
}: Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'className'> & {
|
||||
as?: T
|
||||
className?: string
|
||||
}) {
|
||||
let Component = as ?? 'div'
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={clsx(
|
||||
className,
|
||||
'prose dark:prose-invert',
|
||||
// `html :where(& > *)` is used to select all direct children without an increase in specificity like you'd get from just `& > *`
|
||||
'[html_:where(&>*)]:mx-auto [html_:where(&>*)]:max-w-2xl [html_:where(&>*)]:lg:mx-[calc(50%-min(50%,theme(maxWidth.lg)))] [html_:where(&>*)]:lg:max-w-3xl',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { GridPattern } from "@/components/docs/GridPattern";
|
||||
import { Heading } from "@/components/docs/Heading";
|
||||
import {
|
||||
type MotionValue,
|
||||
motion,
|
||||
useMotionTemplate,
|
||||
useMotionValue,
|
||||
} from "framer-motion";
|
||||
import { Mail, MessageSquare, User, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Resource {
|
||||
href: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
pattern: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof GridPattern>,
|
||||
"width" | "height" | "x"
|
||||
>;
|
||||
}
|
||||
|
||||
const resources: Array<Resource> = [
|
||||
{
|
||||
href: "/contacts",
|
||||
name: "Contacts",
|
||||
description:
|
||||
"Learn about the contact model and how to create, retrieve, update, delete, and list contacts.",
|
||||
icon: User,
|
||||
pattern: {
|
||||
y: 16,
|
||||
squares: [
|
||||
[0, 1],
|
||||
[1, 3],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
href: "/conversations",
|
||||
name: "Conversations",
|
||||
description:
|
||||
"Learn about the conversation model and how to create, retrieve, update, delete, and list conversations.",
|
||||
icon: MessageSquare,
|
||||
pattern: {
|
||||
y: -6,
|
||||
squares: [
|
||||
[-1, 2],
|
||||
[1, 3],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
href: "/messages",
|
||||
name: "Messages",
|
||||
description:
|
||||
"Learn about the message model and how to create, retrieve, update, delete, and list messages.",
|
||||
icon: Mail,
|
||||
pattern: {
|
||||
y: 32,
|
||||
squares: [
|
||||
[0, 2],
|
||||
[1, 4],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
href: "/groups",
|
||||
name: "Groups",
|
||||
description:
|
||||
"Learn about the group model and how to create, retrieve, update, delete, and list groups.",
|
||||
icon: Users,
|
||||
pattern: {
|
||||
y: 22,
|
||||
squares: [[0, 1]],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function ResourceIcon({ icon: Icon }: { icon: Resource["icon"] }) {
|
||||
return (
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-zinc-900/5 ring-1 ring-zinc-900/25 backdrop-blur-[2px] transition duration-300 group-hover:bg-white/50 group-hover:ring-zinc-900/25 dark:bg-white/7.5 dark:ring-white/15 dark:group-hover:bg-emerald-300/10 dark:group-hover:ring-emerald-400">
|
||||
<Icon className="h-5 w-5 fill-zinc-700/10 stroke-zinc-700 transition-colors duration-300 group-hover:stroke-zinc-900 dark:fill-white/10 dark:stroke-zinc-400 dark:group-hover:fill-emerald-300/10 dark:group-hover:stroke-emerald-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourcePattern({
|
||||
mouseX,
|
||||
mouseY,
|
||||
...gridProps
|
||||
}: Resource["pattern"] & {
|
||||
mouseX: MotionValue<number>;
|
||||
mouseY: MotionValue<number>;
|
||||
}) {
|
||||
const maskImage = useMotionTemplate`radial-gradient(180px at ${mouseX}px ${mouseY}px, white, transparent)`;
|
||||
const style = { maskImage, WebkitMaskImage: maskImage };
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none">
|
||||
<div className="absolute inset-0 rounded-2xl transition duration-300 [mask-image:linear-gradient(white,transparent)] group-hover:opacity-50">
|
||||
<GridPattern
|
||||
width={72}
|
||||
height={56}
|
||||
x="50%"
|
||||
className="absolute inset-x-0 inset-y-[-30%] h-[160%] w-full skew-y-[-18deg] fill-black/[0.02] stroke-black/5 dark:fill-white/1 dark:stroke-white/2.5"
|
||||
{...gridProps}
|
||||
/>
|
||||
</div>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-[#D7EDEA] to-[#F4FBDF] opacity-0 transition duration-300 group-hover:opacity-100 dark:from-[#202D2E] dark:to-[#303428]"
|
||||
style={style}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-2xl opacity-0 mix-blend-overlay transition duration-300 group-hover:opacity-100"
|
||||
style={style}
|
||||
>
|
||||
<GridPattern
|
||||
width={72}
|
||||
height={56}
|
||||
x="50%"
|
||||
className="absolute inset-x-0 inset-y-[-30%] h-[160%] w-full skew-y-[-18deg] fill-black/50 stroke-black/70 dark:fill-white/2.5 dark:stroke-white/10"
|
||||
{...gridProps}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Resource({ resource }: { resource: Resource }) {
|
||||
const mouseX = useMotionValue(0);
|
||||
const mouseY = useMotionValue(0);
|
||||
|
||||
function onMouseMove({
|
||||
currentTarget,
|
||||
clientX,
|
||||
clientY,
|
||||
}: React.MouseEvent<HTMLDivElement>) {
|
||||
const { left, top } = currentTarget.getBoundingClientRect();
|
||||
mouseX.set(clientX - left);
|
||||
mouseY.set(clientY - top);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={resource.href}
|
||||
onMouseMove={onMouseMove}
|
||||
className="group relative flex rounded-2xl bg-zinc-50 transition-shadow hover:shadow-md hover:shadow-zinc-900/5 dark:bg-white/2.5 dark:hover:shadow-black/5"
|
||||
>
|
||||
<ResourcePattern {...resource.pattern} mouseX={mouseX} mouseY={mouseY} />
|
||||
<div className="absolute inset-0 rounded-2xl ring-1 ring-inset ring-zinc-900/7.5 group-hover:ring-zinc-900/10 dark:ring-white/10 dark:group-hover:ring-white/20" />
|
||||
<div className="relative rounded-2xl px-4 pb-4 pt-16">
|
||||
<ResourceIcon icon={resource.icon} />
|
||||
<h3 className="mt-4 text-sm font-semibold leading-7 text-zinc-900 dark:text-white">
|
||||
<Link href={resource.href}>
|
||||
<span className="absolute inset-0 rounded-2xl" />
|
||||
{resource.name}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{resource.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Resources() {
|
||||
return (
|
||||
<div className="my-16 xl:max-w-none">
|
||||
<Heading level={2} id="resources">
|
||||
Resources
|
||||
</Heading>
|
||||
<div className="not-prose mt-4 grid grid-cols-1 gap-8 border-t border-zinc-900/5 pt-10 dark:border-white/5 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{resources.map((resource) => (
|
||||
<Resource key={resource.href} resource={resource} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
"use client";
|
||||
|
||||
import { navigation } from "@/components/docs/Navigation";
|
||||
import { type Result } from "@/mdx/search.mjs";
|
||||
import {
|
||||
type AutocompleteApi,
|
||||
createAutocomplete,
|
||||
type AutocompleteState,
|
||||
type AutocompleteCollection,
|
||||
} from "@algolia/autocomplete-core";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
forwardRef,
|
||||
Fragment,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import Highlighter from "react-highlight-words";
|
||||
|
||||
type EmptyObject = Record<string, never>;
|
||||
|
||||
type Autocomplete = AutocompleteApi<
|
||||
Result,
|
||||
React.SyntheticEvent,
|
||||
React.MouseEvent,
|
||||
React.KeyboardEvent
|
||||
>;
|
||||
|
||||
function useAutocomplete({ close }: { close: () => void }) {
|
||||
const id = useId();
|
||||
const router = useRouter();
|
||||
const [autocompleteState, setAutocompleteState] = useState<
|
||||
AutocompleteState<Result> | EmptyObject
|
||||
>({});
|
||||
|
||||
function navigate({ itemUrl }: { itemUrl?: string }) {
|
||||
if (!itemUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(itemUrl);
|
||||
|
||||
if (
|
||||
itemUrl ===
|
||||
window.location.pathname + window.location.search + window.location.hash
|
||||
) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
const [autocomplete] = useState<Autocomplete>(() =>
|
||||
createAutocomplete<
|
||||
Result,
|
||||
React.SyntheticEvent,
|
||||
React.MouseEvent,
|
||||
React.KeyboardEvent
|
||||
>({
|
||||
id,
|
||||
placeholder: "Find something...",
|
||||
defaultActiveItemId: 0,
|
||||
onStateChange({ state }) {
|
||||
setAutocompleteState(state);
|
||||
},
|
||||
shouldPanelOpen({ state }) {
|
||||
return state.query !== "";
|
||||
},
|
||||
navigator: {
|
||||
navigate,
|
||||
},
|
||||
getSources({ query }) {
|
||||
return import("@/mdx/search.mjs").then(({ search }) => {
|
||||
return [
|
||||
{
|
||||
sourceId: "documentation",
|
||||
getItems() {
|
||||
return search(query, { limit: 5 });
|
||||
},
|
||||
getItemUrl({ item }) {
|
||||
return item.url;
|
||||
},
|
||||
onSelect: navigate,
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return { autocomplete, autocompleteState };
|
||||
}
|
||||
|
||||
function SearchIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12.01 12a4.25 4.25 0 1 0-6.02-6 4.25 4.25 0 0 0 6.02 6Zm0 0 3.24 3.25"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function NoResultsIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12.01 12a4.237 4.237 0 0 0 1.24-3c0-.62-.132-1.207-.37-1.738M12.01 12A4.237 4.237 0 0 1 9 13.25c-.635 0-1.237-.14-1.777-.388M12.01 12l3.24 3.25m-3.715-9.661a4.25 4.25 0 0 0-5.975 5.908M4.5 15.5l11-11"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||
<circle cx="10" cy="10" r="5.5" strokeLinejoin="round" />
|
||||
<path
|
||||
stroke={`url(#${id})`}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.5 10a5.5 5.5 0 1 0-5.5 5.5"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={id}
|
||||
x1="13"
|
||||
x2="9.5"
|
||||
y1="9"
|
||||
y2="15"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="currentColor" />
|
||||
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HighlightQuery({ text, query }: { text: string; query: string }) {
|
||||
return (
|
||||
<Highlighter
|
||||
highlightClassName="underline bg-transparent text-emerald-500"
|
||||
searchWords={[query]}
|
||||
autoEscape={true}
|
||||
textToHighlight={text}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResult({
|
||||
result,
|
||||
resultIndex,
|
||||
autocomplete,
|
||||
collection,
|
||||
query,
|
||||
}: {
|
||||
result: Result;
|
||||
resultIndex: number;
|
||||
autocomplete: Autocomplete;
|
||||
collection: AutocompleteCollection<Result>;
|
||||
query: string;
|
||||
}) {
|
||||
const id = useId();
|
||||
|
||||
const sectionTitle = navigation.find((section) =>
|
||||
section.links.find((link) => link.href === result.url.split("#")[0])
|
||||
)?.title;
|
||||
const hierarchy = [sectionTitle, result.pageTitle].filter(
|
||||
(x): x is string => typeof x === "string"
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={clsx(
|
||||
"group block cursor-default px-4 py-3 aria-selected:bg-zinc-50 dark:aria-selected:bg-zinc-800/50",
|
||||
resultIndex > 0 && "border-t border-zinc-100 dark:border-zinc-800"
|
||||
)}
|
||||
aria-labelledby={`${id}-hierarchy ${id}-title`}
|
||||
{...autocomplete.getItemProps({
|
||||
item: result,
|
||||
source: collection.source,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
id={`${id}-title`}
|
||||
aria-hidden="true"
|
||||
className="text-sm font-medium text-zinc-900 group-aria-selected:text-emerald-500 dark:text-white"
|
||||
>
|
||||
<HighlightQuery text={result.title} query={query} />
|
||||
</div>
|
||||
{hierarchy.length > 0 && (
|
||||
<div
|
||||
id={`${id}-hierarchy`}
|
||||
aria-hidden="true"
|
||||
className="mt-1 truncate whitespace-nowrap text-2xs text-zinc-500"
|
||||
>
|
||||
{hierarchy.map((item, itemIndex, items) => (
|
||||
<Fragment key={itemIndex}>
|
||||
<HighlightQuery text={item} query={query} />
|
||||
<span
|
||||
className={
|
||||
itemIndex === items.length - 1
|
||||
? "sr-only"
|
||||
: "mx-2 text-zinc-300 dark:text-zinc-700"
|
||||
}
|
||||
>
|
||||
/
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResults({
|
||||
autocomplete,
|
||||
query,
|
||||
collection,
|
||||
}: {
|
||||
autocomplete: Autocomplete;
|
||||
query: string;
|
||||
collection: AutocompleteCollection<Result>;
|
||||
}) {
|
||||
if (collection.items.length === 0) {
|
||||
return (
|
||||
<div className="p-6 text-center">
|
||||
<NoResultsIcon className="mx-auto h-5 w-5 stroke-zinc-900 dark:stroke-zinc-600" />
|
||||
<p className="mt-2 text-xs text-zinc-700 dark:text-zinc-400">
|
||||
Nothing found for{" "}
|
||||
<strong className="break-words font-semibold text-zinc-900 dark:text-white">
|
||||
‘{query}’
|
||||
</strong>
|
||||
. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul {...autocomplete.getListProps()}>
|
||||
{collection.items.map((result, resultIndex) => (
|
||||
<SearchResult
|
||||
key={result.url}
|
||||
result={result}
|
||||
resultIndex={resultIndex}
|
||||
autocomplete={autocomplete}
|
||||
collection={collection}
|
||||
query={query}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
const SearchInput = forwardRef<
|
||||
React.ElementRef<"input">,
|
||||
{
|
||||
autocomplete: Autocomplete;
|
||||
autocompleteState: AutocompleteState<Result> | EmptyObject;
|
||||
onClose: () => void;
|
||||
}
|
||||
>(function SearchInput({ autocomplete, autocompleteState, onClose }, inputRef) {
|
||||
const inputProps = autocomplete.getInputProps({ inputElement: null });
|
||||
|
||||
return (
|
||||
<div className="group relative flex h-12">
|
||||
<SearchIcon className="pointer-events-none absolute left-3 top-0 h-full w-5 stroke-zinc-500" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={clsx(
|
||||
"flex-auto appearance-none bg-transparent pl-10 text-zinc-900 outline-none placeholder:text-zinc-500 focus:w-full focus:flex-none dark:text-white sm:text-sm [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden [&::-webkit-search-results-button]:hidden [&::-webkit-search-results-decoration]:hidden",
|
||||
autocompleteState.status === "stalled" ? "pr-11" : "pr-4"
|
||||
)}
|
||||
{...inputProps}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Escape" &&
|
||||
!autocompleteState.isOpen &&
|
||||
autocompleteState.query === ""
|
||||
) {
|
||||
// In Safari, closing the dialog with the escape key can sometimes cause the scroll position to jump to the
|
||||
// bottom of the page. This is a workaround for that until we can figure out a proper fix in Headless UI.
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
onClose();
|
||||
} else {
|
||||
inputProps.onKeyDown(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{autocompleteState.status === "stalled" && (
|
||||
<div className="absolute inset-y-0 right-3 flex items-center">
|
||||
<LoadingIcon className="h-5 w-5 animate-spin stroke-zinc-200 text-zinc-900 dark:stroke-zinc-800 dark:text-emerald-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function SearchDialog({
|
||||
open,
|
||||
setOpen,
|
||||
className,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const formRef = useRef<React.ElementRef<"form">>(null);
|
||||
const panelRef = useRef<React.ElementRef<"div">>(null);
|
||||
const inputRef = useRef<React.ElementRef<typeof SearchInput>>(null);
|
||||
const { autocomplete, autocompleteState } = useAutocomplete({
|
||||
close() {
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [pathname, searchParams, setOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
return;
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open, setOpen]);
|
||||
|
||||
return (
|
||||
<Transition.Root
|
||||
show={open}
|
||||
as={Fragment}
|
||||
afterLeave={() => autocomplete.setQuery("")}
|
||||
>
|
||||
<Dialog
|
||||
onClose={setOpen}
|
||||
className={clsx("fixed inset-0 z-50", className)}
|
||||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-zinc-400/25 backdrop-blur-sm dark:bg-black/40" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto px-4 py-4 sm:px-6 sm:py-20 md:py-32 lg:px-8 lg:py-[15vh]">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="mx-auto transform-gpu overflow-hidden rounded-lg bg-zinc-50 shadow-xl ring-1 ring-zinc-900/7.5 dark:bg-zinc-900 dark:ring-zinc-800 sm:max-w-xl">
|
||||
<div {...autocomplete.getRootProps({})}>
|
||||
<form
|
||||
ref={formRef}
|
||||
{...autocomplete.getFormProps({
|
||||
inputElement: inputRef.current,
|
||||
})}
|
||||
>
|
||||
<SearchInput
|
||||
ref={inputRef}
|
||||
autocomplete={autocomplete}
|
||||
autocompleteState={autocompleteState}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="border-t border-zinc-200 bg-white empty:hidden dark:border-zinc-100/5 dark:bg-white/2.5"
|
||||
{...autocomplete.getPanelProps({})}
|
||||
>
|
||||
{autocompleteState.isOpen && (
|
||||
<SearchResults
|
||||
autocomplete={autocomplete}
|
||||
query={autocompleteState.query}
|
||||
collection={autocompleteState.collections[0]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function useSearchProps() {
|
||||
const buttonRef = useRef<React.ElementRef<"button">>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return {
|
||||
buttonProps: {
|
||||
ref: buttonRef,
|
||||
onClick() {
|
||||
setOpen(true);
|
||||
},
|
||||
},
|
||||
dialogProps: {
|
||||
open,
|
||||
setOpen: useCallback(
|
||||
(open: boolean) => {
|
||||
const { width = 0, height = 0 } =
|
||||
buttonRef.current?.getBoundingClientRect() ?? {};
|
||||
if (!open || (width !== 0 && height !== 0)) {
|
||||
setOpen(open);
|
||||
}
|
||||
},
|
||||
[setOpen]
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function Search() {
|
||||
const [modifierKey, setModifierKey] = useState<string>();
|
||||
const { buttonProps, dialogProps } = useSearchProps();
|
||||
|
||||
useEffect(() => {
|
||||
setModifierKey(
|
||||
/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) ? "⌘" : "Ctrl "
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="hidden lg:block lg:max-w-md lg:flex-auto">
|
||||
<button
|
||||
type="button"
|
||||
className="hidden h-8 w-full items-center gap-2 rounded-full bg-white pl-2 pr-3 text-sm text-zinc-500 ring-1 ring-zinc-900/10 transition hover:ring-zinc-900/20 ui-not-focus-visible:outline-none dark:bg-white/5 dark:text-zinc-400 dark:ring-inset dark:ring-white/10 dark:hover:ring-white/20 lg:flex"
|
||||
{...buttonProps}
|
||||
>
|
||||
<SearchIcon className="h-5 w-5 stroke-current" />
|
||||
Find something...
|
||||
<kbd className="ml-auto text-2xs text-zinc-400 dark:text-zinc-500">
|
||||
<kbd className="font-sans">{modifierKey}</kbd>
|
||||
<kbd className="font-sans">K</kbd>
|
||||
</kbd>
|
||||
</button>
|
||||
<Suspense fallback={null}>
|
||||
<SearchDialog className="hidden lg:block" {...dialogProps} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileSearch() {
|
||||
const { buttonProps, dialogProps } = useSearchProps();
|
||||
|
||||
return (
|
||||
<div className="contents lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md transition hover:bg-zinc-900/5 ui-not-focus-visible:outline-none dark:hover:bg-white/5 lg:hidden"
|
||||
aria-label="Find something..."
|
||||
{...buttonProps}
|
||||
>
|
||||
<SearchIcon className="h-5 w-5 stroke-zinc-900 dark:stroke-white" />
|
||||
</button>
|
||||
<Suspense fallback={null}>
|
||||
<SearchDialog className="lg:hidden" {...dialogProps} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { remToPx } from "@/lib/remToPx";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type StoreApi, createStore, useStore } from "zustand";
|
||||
|
||||
export interface Section {
|
||||
id: string;
|
||||
title: string;
|
||||
offsetRem?: number;
|
||||
tag?: string;
|
||||
headingRef?: React.RefObject<HTMLHeadingElement>;
|
||||
}
|
||||
|
||||
interface SectionState {
|
||||
sections: Array<Section>;
|
||||
visibleSections: Array<string>;
|
||||
setVisibleSections: (visibleSections: Array<string>) => void;
|
||||
registerHeading: ({
|
||||
id,
|
||||
ref,
|
||||
offsetRem,
|
||||
}: {
|
||||
id: string;
|
||||
ref: React.RefObject<HTMLHeadingElement>;
|
||||
offsetRem: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function createSectionStore(sections: Array<Section>) {
|
||||
return createStore<SectionState>()((set) => ({
|
||||
sections,
|
||||
visibleSections: [],
|
||||
setVisibleSections: (visibleSections) =>
|
||||
set((state) =>
|
||||
state.visibleSections.join() === visibleSections.join()
|
||||
? {}
|
||||
: { visibleSections }
|
||||
),
|
||||
registerHeading: ({ id, ref, offsetRem }) =>
|
||||
set((state) => {
|
||||
return {
|
||||
sections: state.sections.map((section) => {
|
||||
if (section.id === id) {
|
||||
return {
|
||||
...section,
|
||||
headingRef: ref,
|
||||
offsetRem,
|
||||
};
|
||||
}
|
||||
return section;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
function useVisibleSections(sectionStore: StoreApi<SectionState>) {
|
||||
const setVisibleSections = useStore(
|
||||
sectionStore,
|
||||
(s) => s.setVisibleSections
|
||||
);
|
||||
const sections = useStore(sectionStore, (s) => s.sections);
|
||||
|
||||
useEffect(() => {
|
||||
function checkVisibleSections() {
|
||||
const { innerHeight, scrollY } = window;
|
||||
const newVisibleSections = [];
|
||||
|
||||
for (
|
||||
let sectionIndex = 0;
|
||||
sectionIndex < sections.length;
|
||||
sectionIndex++
|
||||
) {
|
||||
const { id, headingRef, offsetRem = 0 } = sections[sectionIndex];
|
||||
|
||||
if (!headingRef?.current) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const offset = remToPx(offsetRem);
|
||||
const top = headingRef.current.getBoundingClientRect().top + scrollY;
|
||||
|
||||
if (sectionIndex === 0 && top - offset > scrollY) {
|
||||
newVisibleSections.push("_top");
|
||||
}
|
||||
|
||||
const nextSection = sections[sectionIndex + 1];
|
||||
const bottom =
|
||||
(nextSection?.headingRef?.current?.getBoundingClientRect().top ??
|
||||
Infinity) +
|
||||
scrollY -
|
||||
remToPx(nextSection?.offsetRem ?? 0);
|
||||
|
||||
if (
|
||||
(top > scrollY && top < scrollY + innerHeight) ||
|
||||
(bottom > scrollY && bottom < scrollY + innerHeight) ||
|
||||
(top <= scrollY && bottom >= scrollY + innerHeight)
|
||||
) {
|
||||
newVisibleSections.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
setVisibleSections(newVisibleSections);
|
||||
}
|
||||
|
||||
const raf = window.requestAnimationFrame(() => checkVisibleSections());
|
||||
window.addEventListener("scroll", checkVisibleSections, { passive: true });
|
||||
window.addEventListener("resize", checkVisibleSections);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
window.removeEventListener("scroll", checkVisibleSections);
|
||||
window.removeEventListener("resize", checkVisibleSections);
|
||||
};
|
||||
}, [setVisibleSections, sections]);
|
||||
}
|
||||
|
||||
const SectionStoreContext = createContext<StoreApi<SectionState> | null>(null);
|
||||
|
||||
const useIsomorphicLayoutEffect =
|
||||
typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||
|
||||
export function SectionProvider({
|
||||
sections,
|
||||
children,
|
||||
}: {
|
||||
sections: Array<Section>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [sectionStore] = useState(() => createSectionStore(sections));
|
||||
|
||||
useVisibleSections(sectionStore);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
sectionStore.setState({ sections });
|
||||
}, [sectionStore, sections]);
|
||||
|
||||
return (
|
||||
<SectionStoreContext.Provider value={sectionStore}>
|
||||
{children}
|
||||
</SectionStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSectionStore<T>(selector: (state: SectionState) => T) {
|
||||
const store = useContext(SectionStoreContext);
|
||||
return useStore(store!, selector);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
const variantStyles = {
|
||||
small: '',
|
||||
medium: 'rounded-lg px-1.5 ring-1 ring-inset',
|
||||
}
|
||||
|
||||
const colorStyles = {
|
||||
emerald: {
|
||||
small: 'text-emerald-500 dark:text-emerald-400',
|
||||
medium:
|
||||
'ring-emerald-300 dark:ring-emerald-400/30 bg-emerald-400/10 text-emerald-500 dark:text-emerald-400',
|
||||
},
|
||||
sky: {
|
||||
small: 'text-sky-500',
|
||||
medium:
|
||||
'ring-sky-300 bg-sky-400/10 text-sky-500 dark:ring-sky-400/30 dark:bg-sky-400/10 dark:text-sky-400',
|
||||
},
|
||||
amber: {
|
||||
small: 'text-amber-500',
|
||||
medium:
|
||||
'ring-amber-300 bg-amber-400/10 text-amber-500 dark:ring-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400',
|
||||
},
|
||||
rose: {
|
||||
small: 'text-red-500 dark:text-rose-500',
|
||||
medium:
|
||||
'ring-rose-200 bg-rose-50 text-red-500 dark:ring-rose-500/20 dark:bg-rose-400/10 dark:text-rose-400',
|
||||
},
|
||||
zinc: {
|
||||
small: 'text-zinc-400 dark:text-zinc-500',
|
||||
medium:
|
||||
'ring-zinc-200 bg-zinc-50 text-zinc-500 dark:ring-zinc-500/20 dark:bg-zinc-400/10 dark:text-zinc-400',
|
||||
},
|
||||
}
|
||||
|
||||
const valueColorMap = {
|
||||
GET: 'emerald',
|
||||
POST: 'sky',
|
||||
PUT: 'amber',
|
||||
DELETE: 'rose',
|
||||
} as Record<string, keyof typeof colorStyles>
|
||||
|
||||
export function Tag({
|
||||
children,
|
||||
variant = 'medium',
|
||||
color = valueColorMap[children] ?? 'emerald',
|
||||
}: {
|
||||
children: keyof typeof valueColorMap & (string | {})
|
||||
variant?: keyof typeof variantStyles
|
||||
color?: keyof typeof colorStyles
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'font-mono text-[0.625rem] font-semibold leading-6',
|
||||
variantStyles[variant],
|
||||
colorStyles[color][variant],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
function SunIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||
<path d="M12.5 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Z" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
d="M10 5.5v-1M13.182 6.818l.707-.707M14.5 10h1M13.182 13.182l.707.707M10 15.5v-1M6.11 13.889l.708-.707M4.5 10h1M6.11 6.111l.708.707"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MoonIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||
<path d="M15.224 11.724a5.5 5.5 0 0 1-6.949-6.949 5.5 5.5 0 1 0 6.949 6.949Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
let { resolvedTheme, setTheme } = useTheme()
|
||||
let otherTheme = resolvedTheme === 'dark' ? 'light' : 'dark'
|
||||
let [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md transition hover:bg-zinc-900/5 dark:hover:bg-white/5"
|
||||
aria-label={mounted ? `Switch to ${otherTheme} theme` : 'Toggle theme'}
|
||||
onClick={() => setTheme(otherTheme)}
|
||||
>
|
||||
<SunIcon className="h-5 w-5 stroke-zinc-900 dark:hidden" />
|
||||
<MoonIcon className="hidden h-5 w-5 stroke-white dark:block" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Feedback } from "@/components/docs/Feedback";
|
||||
import { Heading } from "@/components/docs/Heading";
|
||||
import { Prose } from "@/components/docs/Prose";
|
||||
import { cn } from "@/lib/utils";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
|
||||
export const a = Link;
|
||||
export { Button } from "@/components/docs/Button";
|
||||
export { CodeGroup, Code as code, Pre as pre } from "@/components/docs/Code";
|
||||
|
||||
export function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<article className="flex h-full flex-col pb-10 pt-16">
|
||||
<Prose className="flex-auto">{children}</Prose>
|
||||
<footer className="mx-auto mt-16 w-full max-w-2xl lg:max-w-5xl">
|
||||
<Feedback />
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export const h2 = function H2(
|
||||
props: Omit<React.ComponentPropsWithoutRef<typeof Heading>, "level">
|
||||
) {
|
||||
return <Heading level={2} {...props} />;
|
||||
};
|
||||
|
||||
function InfoIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" {...props}>
|
||||
<circle cx="8" cy="8" r="8" strokeWidth="0" />
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M6.75 7.75h1.5v3.5"
|
||||
/>
|
||||
<circle cx="8" cy="4" r=".5" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Image({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"img">) {
|
||||
return (
|
||||
<img
|
||||
{...props}
|
||||
className={cn(
|
||||
"w-full rounded-2xl ring-1 ring-stone-200 p-2 bg-stone-100 object-contain",
|
||||
className
|
||||
)}
|
||||
loading="lazy"
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Note({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="my-6 flex gap-2.5 rounded-2xl border border-emerald-500/20 bg-emerald-50/50 p-4 leading-6 text-emerald-900 dark:border-emerald-500/30 dark:bg-emerald-500/5 dark:text-emerald-200 dark:[--tw-prose-links-hover:theme(colors.emerald.300)] dark:[--tw-prose-links:theme(colors.white)]">
|
||||
<InfoIcon className="mt-1 h-4 w-4 flex-none fill-emerald-500 stroke-white dark:fill-emerald-200/20 dark:stroke-emerald-200" />
|
||||
<div className="[&>:first-child]:mt-0 [&>:last-child]:mb-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Row({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 items-start gap-x-16 gap-y-10 xl:max-w-none xl:grid-cols-2">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Col({
|
||||
children,
|
||||
sticky = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
sticky?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"[&>:first-child]:mt-0 [&>:last-child]:mb-0",
|
||||
sticky && "xl:sticky xl:top-24"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Properties({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="my-6">
|
||||
<ul
|
||||
role="list"
|
||||
className="m-0 max-w-[calc(theme(maxWidth.lg)-theme(spacing.8))] list-none divide-y divide-zinc-900/5 p-0 dark:divide-white/5"
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Property({
|
||||
name,
|
||||
children,
|
||||
type,
|
||||
}: {
|
||||
name: string;
|
||||
children: React.ReactNode;
|
||||
type?: string;
|
||||
}) {
|
||||
return (
|
||||
<li className="m-0 px-0 py-4 first:pt-0 last:pb-0">
|
||||
<dl className="m-0 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<dt className="sr-only">Name</dt>
|
||||
<dd>
|
||||
<code>{name}</code>
|
||||
</dd>
|
||||
{type && (
|
||||
<>
|
||||
<dt className="sr-only">Type</dt>
|
||||
<dd className="font-mono text-xs text-zinc-400 dark:text-zinc-500">
|
||||
{type}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="sr-only">Description</dt>
|
||||
<dd className="w-full flex-none [&>:first-child]:mt-0 [&>:last-child]:mb-0">
|
||||
{children}
|
||||
</dd>
|
||||
</dl>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user