mirror of
https://github.com/versia-pub/docs.git
synced 2026-03-13 02:49:16 +01:00
feat: ✨ Initialize rewrite
This commit is contained in:
parent
47ce9bd9f8
commit
f39d34b769
143 changed files with 7257 additions and 4032 deletions
|
|
@ -1,19 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
const currentNews = {
|
||||
id: "lysand3",
|
||||
title: "Lysand 3.0",
|
||||
description: "Lysand 3.0 is now available!",
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="fixed inset-x-0 top-0 flex items-center h-10 gap-x-6 overflow-hidden bg-black px-4 py-2.5 sm:px-3.5 sm:before:flex-1 z-50">
|
||||
<div class="flex flex-wrap justify-center gap-x-4 gap-y-2 w-full">
|
||||
<p class="text-sm text-gray-50">
|
||||
<strong class="font-semibold">{{
|
||||
currentNews.title
|
||||
}}</strong> • {{ currentNews.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
82
components/Button.tsx
Normal file
82
components/Button.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
function ArrowIcon(props: 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";
|
||||
} & (
|
||||
| ComponentPropsWithoutRef<typeof Link>
|
||||
| (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",
|
||||
variantStyles[variant],
|
||||
className,
|
||||
);
|
||||
|
||||
const arrowIcon = (
|
||||
<ArrowIcon
|
||||
className={clsx(
|
||||
"mt-0.5 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>
|
||||
);
|
||||
}
|
||||
393
components/Code.tsx
Normal file
393
components/Code.tsx
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
"use client";
|
||||
|
||||
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
Children,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
isValidElement,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { create } from "zustand";
|
||||
|
||||
import { Tag } from "./Tag";
|
||||
|
||||
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: 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: 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: 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 && (
|
||||
<TabList 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>
|
||||
))}
|
||||
</TabList>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeGroupPanels({
|
||||
children,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof CodePanel>) {
|
||||
const hasTabs = Children.count(children) > 1;
|
||||
|
||||
if (hasTabs) {
|
||||
return (
|
||||
<TabPanels>
|
||||
{Children.map(children, (child) => (
|
||||
<TabPanel>
|
||||
<CodePanel {...props}>{child}</CodePanel>
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
);
|
||||
}
|
||||
|
||||
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: string[];
|
||||
addPreferredLanguage: (language: string) => void;
|
||||
}>()((set) => ({
|
||||
preferredLanguages: [],
|
||||
addPreferredLanguage: (language) =>
|
||||
set((state) => ({
|
||||
preferredLanguages: [
|
||||
...state.preferredLanguages.filter(
|
||||
(preferredLanguage) => preferredLanguage !== language,
|
||||
),
|
||||
language,
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
function useTabGroupProps(availableLanguages: 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
|
||||
}: 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 ? (
|
||||
<TabGroup {...tabGroupProps} className={containerClassName}>
|
||||
<div className="not-prose">
|
||||
{header}
|
||||
{panels}
|
||||
</div>
|
||||
</TabGroup>
|
||||
) : (
|
||||
<div className={containerClassName}>
|
||||
<div className="not-prose">
|
||||
{header}
|
||||
{panels}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CodeGroupContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function Code({ children, ...props }: 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 (
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation>
|
||||
// biome-ignore lint/style/useNamingConvention: <explanation>
|
||||
<code {...props} dangerouslySetInnerHTML={{ __html: children }} />
|
||||
);
|
||||
}
|
||||
|
||||
return <code {...props}>{children}</code>;
|
||||
}
|
||||
|
||||
export function Pre({
|
||||
children,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof CodeGroup>) {
|
||||
const isGrouped = useContext(CodeGroupContext);
|
||||
|
||||
if (isGrouped) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return <CodeGroup {...props}>{children}</CodeGroup>;
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
<template>
|
||||
<div class="mt-12">
|
||||
<div class="max-w-3xl">
|
||||
<h1>Made by developers</h1>
|
||||
<p>
|
||||
Lysand is designed and maintained by the developers of the Lysand Server, which uses Lysand for
|
||||
federation. This community could include you! Check out our <a
|
||||
href="https://github.com/lysand-org/lysand">Git repository</a> to see how you can contribute.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="!mt-8 grid items-start gap-x-6 gap-y-6 sm:mt-16 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 lg:gap-x-8">
|
||||
<div v-for="feature in features" :key="feature.name"
|
||||
class="flex flex-row h-32 p-5 items-center gap-x-4 bg-[var(--vp-c-bg-soft)] shadow rounded duration-200 hover:ring-2 hover:scale-[101%] ring-[var(--vp-color-primary)]">
|
||||
<div class="aspect-square flex items-center justify-center overflow-hidden rounded shrink-0 h-full">
|
||||
<iconify-icon :icon="feature.icon" class="text-[var(--vp-color-primary)] text-5xl" />
|
||||
</div>
|
||||
<div class="text-pretty">
|
||||
<h3 class="!text-base font-medium !mt-0">{{ feature.name }}</h3>
|
||||
<p class="!mt-1 !mb-0 !text-sm">{{ feature.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const features = [
|
||||
{
|
||||
name: "JSON-based APIs",
|
||||
description: "Simple JSON objects are used to represent all data.",
|
||||
icon: "bx:bx-code-alt",
|
||||
},
|
||||
{
|
||||
name: "MIT Licensed",
|
||||
description:
|
||||
"Lysand is licensed under the MIT License, which allows you to use it for any purpose.",
|
||||
icon: "bx:bx-shield",
|
||||
},
|
||||
{
|
||||
name: "Built-in namespaced extensions",
|
||||
description:
|
||||
"Extensions for common use cases are built-in, such as custom emojis and reactions",
|
||||
icon: "bx:bx-extension",
|
||||
},
|
||||
{
|
||||
name: "Easy to implement",
|
||||
description:
|
||||
"Lysand is designed to be easy to implement in any language.",
|
||||
icon: "bx:bx-code-block",
|
||||
},
|
||||
{
|
||||
name: "Secure by default",
|
||||
description:
|
||||
"All requests are signed using advanced cryptographic algorithms.",
|
||||
icon: "bx:bx-shield-alt",
|
||||
},
|
||||
{
|
||||
name: "No Mastodon Situation",
|
||||
description:
|
||||
"Standardization is heavy and designed to break vendor lock-in.",
|
||||
icon: "bx:bx-code-curly",
|
||||
},
|
||||
{
|
||||
name: "In-Depth Security Docs",
|
||||
description:
|
||||
"Docs provide lots of information on how to program a secure server.",
|
||||
icon: "bx:bx-shield-x",
|
||||
},
|
||||
{
|
||||
name: "TypeScript Types",
|
||||
description: "TypeScript types are provided for all objects.",
|
||||
icon: "bx:bx-code",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
110
components/Feedback.tsx
Normal file
110
components/Feedback.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import { Transition } from "@headlessui/react";
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
type ElementRef,
|
||||
type FormEvent,
|
||||
forwardRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
function CheckIcon(props: 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<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<
|
||||
ElementRef<"form">,
|
||||
Pick<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<ElementRef<"div">>(
|
||||
// biome-ignore lint/style/useNamingConvention: <explanation>
|
||||
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() {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
// event.nativeEvent.submitter.dataset.response
|
||||
// => "yes" or "no"
|
||||
|
||||
setSubmitted(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-8">
|
||||
<Transition
|
||||
show={!submitted}
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
leave="pointer-events-none duration-300"
|
||||
>
|
||||
<FeedbackForm onSubmit={onSubmit} />
|
||||
</Transition>
|
||||
<Transition
|
||||
show={submitted}
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
enter="delay-150 duration-300"
|
||||
>
|
||||
<FeedbackThanks />
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
components/Footer.tsx
Normal file
153
components/Footer.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import type { ComponentPropsWithoutRef, ComponentType, ReactNode } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { navigation } from "./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={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{nextPage && (
|
||||
<div className="ml-auto flex flex-col items-end gap-3">
|
||||
<PageLink label="Next" page={nextPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path d="M11.1527 8.92804L16.2525 3H15.044L10.6159 8.14724L7.07919 3H3L8.34821 10.7835L3 17H4.20855L8.88474 11.5643L12.6198 17H16.699L11.1524 8.92804H11.1527ZM9.49748 10.8521L8.95559 10.077L4.644 3.90978H6.50026L9.97976 8.88696L10.5216 9.66202L15.0446 16.1316H13.1883L9.49748 10.8524V10.8521Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubIcon(props: 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: 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: ComponentType<{ className?: string }>;
|
||||
children: 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 sm:flex-row dark:border-white/5">
|
||||
<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={XIcon}>
|
||||
Follow us on X
|
||||
</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>
|
||||
);
|
||||
}
|
||||
61
components/GridPattern.tsx
Normal file
61
components/GridPattern.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { type ComponentPropsWithoutRef, useId } from "react";
|
||||
|
||||
export function GridPattern({
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
squares,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<"svg"> & {
|
||||
width: number;
|
||||
height: number;
|
||||
x: string | number;
|
||||
y: string | number;
|
||||
squares: [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 && (
|
||||
// biome-ignore lint/a11y/noSvgWithoutTitle: <explanation>
|
||||
<svg
|
||||
x={x}
|
||||
y={y}
|
||||
className="overflow-visible"
|
||||
aria-label="Grid of squares"
|
||||
>
|
||||
{squares.map(([x, y]) => (
|
||||
<rect
|
||||
strokeWidth="0"
|
||||
key={`${x}-${y}`}
|
||||
width={width + 1}
|
||||
height={height + 1}
|
||||
x={x * width}
|
||||
y={y * height}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
58
components/Guides.tsx
Normal file
58
components/Guides.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { Button } from "./Button";
|
||||
import { Heading } from "./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 sm:grid-cols-2 xl:grid-cols-4 dark:border-white/5">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
104
components/Header.tsx
Normal file
104
components/Header.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import clsx from "clsx";
|
||||
import { motion, useScroll, useTransform } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ElementRef,
|
||||
type ReactNode,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
|
||||
import { Button } from "./Button";
|
||||
import { Logo } from "./Logo";
|
||||
import {
|
||||
MobileNavigation,
|
||||
useIsInsideMobileNavigation,
|
||||
} from "./MobileNavigation";
|
||||
import { useMobileNavigationStore } from "./MobileNavigation";
|
||||
import { MobileSearch, Search } from "./Search";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
function TopLevelNavItem({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
children: 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<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 lg:left-72 xl:left-80 dark:backdrop-blur",
|
||||
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 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>
|
||||
</div>
|
||||
<div className="flex items-center gap-5">
|
||||
<nav className="hidden md:block">
|
||||
<ul 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
126
components/Heading.tsx
Normal file
126
components/Heading.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"use client";
|
||||
|
||||
import { useInView } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
import { remToPx } from "../lib/remToPx";
|
||||
import { useSectionStore } from "./SectionProvider";
|
||||
import { Tag } from "./Tag";
|
||||
|
||||
function AnchorIcon(props: 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: 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
|
||||
}: 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
32
components/HeroPattern.tsx
Normal file
32
components/HeroPattern.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { GridPattern } from "./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>
|
||||
);
|
||||
}
|
||||
47
components/Layout.tsx
Normal file
47
components/Layout.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Footer } from "./Footer";
|
||||
import { Header } from "./Header";
|
||||
import { Logo } from "./Logo";
|
||||
import { Navigation } from "./Navigation";
|
||||
import { type Section, SectionProvider } from "./SectionProvider";
|
||||
|
||||
export function Layout({
|
||||
children,
|
||||
allSections,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
allSections: Record<string, Section[]>;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<SectionProvider sections={allSections[pathname] ?? []}>
|
||||
<div className="h-full lg:ml-72 xl:ml-80">
|
||||
<motion.header
|
||||
layoutScroll={true}
|
||||
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 xl:w-80 lg:dark:border-white/10">
|
||||
<div className="hidden lg:flex">
|
||||
<Link href="/" aria-label="Home">
|
||||
<Logo className="h-6" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
89
components/Libraries.tsx
Normal file
89
components/Libraries.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import Image from "next/image";
|
||||
|
||||
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 { Button } from "./Button";
|
||||
import { Heading } from "./Heading";
|
||||
|
||||
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 sm:grid-cols-2 xl:max-w-none xl:grid-cols-3 dark:border-white/5">
|
||||
{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={true}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
components/Logo.tsx
Normal file
16
components/Logo.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function Logo(props: 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>
|
||||
);
|
||||
}
|
||||
182
components/MobileNavigation.tsx
Normal file
182
components/MobileNavigation.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
Transition,
|
||||
TransitionChild,
|
||||
} from "@headlessui/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
type MouseEvent,
|
||||
Suspense,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { create } from "zustand";
|
||||
|
||||
import { Header } from "./Header";
|
||||
import { Navigation } from "./Navigation";
|
||||
|
||||
function MenuIcon(props: 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: 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: 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 show={isOpen}>
|
||||
<Dialog
|
||||
onClickCapture={onClickDialog}
|
||||
onClose={close}
|
||||
className="fixed inset-0 z-50 lg:hidden"
|
||||
>
|
||||
<TransitionChild
|
||||
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" />
|
||||
</TransitionChild>
|
||||
|
||||
<DialogPanel>
|
||||
<TransitionChild
|
||||
enter="duration-300 ease-out"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="duration-200 ease-in"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Header />
|
||||
</TransitionChild>
|
||||
|
||||
<TransitionChild
|
||||
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={true}
|
||||
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 min-[416px]:max-w-sm sm:px-6 sm:pb-10 dark:bg-zinc-900 dark:ring-zinc-800"
|
||||
>
|
||||
<Navigation />
|
||||
</motion.div>
|
||||
</TransitionChild>
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
292
components/Navigation.tsx
Normal file
292
components/Navigation.tsx
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { AnimatePresence, motion, useIsPresent } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { type ComponentPropsWithoutRef, type ReactNode, useRef } from "react";
|
||||
|
||||
import { remToPx } from "../lib/remToPx";
|
||||
import { Button } from "./Button";
|
||||
import { useIsInsideMobileNavigation } from "./MobileNavigation";
|
||||
import { useSectionStore } from "./SectionProvider";
|
||||
import { Tag } from "./Tag";
|
||||
|
||||
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: 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: 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={true}
|
||||
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={true}
|
||||
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={true}
|
||||
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 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={true}
|
||||
>
|
||||
{section.title}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export const navigation: NavGroup[] = [
|
||||
{
|
||||
title: "Guides",
|
||||
links: [
|
||||
{ title: "Introduction", href: "/" },
|
||||
{ title: "Quickstart", href: "/quickstart" },
|
||||
{ title: "SDKs", href: "/sdks" },
|
||||
{ title: "Authentication", href: "/authentication" },
|
||||
{ title: "Pagination", href: "/pagination" },
|
||||
{ title: "Errors", href: "/errors" },
|
||||
{ title: "Webhooks", href: "/webhooks" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Resources",
|
||||
links: [
|
||||
{ title: "Contacts", href: "/contacts" },
|
||||
{ title: "Conversations", href: "/conversations" },
|
||||
{ title: "Messages", href: "/messages" },
|
||||
{ title: "Groups", href: "/groups" },
|
||||
{ title: "Attachments", href: "/attachments" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function Navigation(props: ComponentPropsWithoutRef<"nav">) {
|
||||
return (
|
||||
<nav {...props}>
|
||||
<ul>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
25
components/Prose.tsx
Normal file
25
components/Prose.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import clsx from "clsx";
|
||||
import type { ComponentPropsWithoutRef, ElementType } from "react";
|
||||
|
||||
export function Prose<T extends ElementType = "div">({
|
||||
as,
|
||||
className,
|
||||
...props
|
||||
}: Omit<ComponentPropsWithoutRef<T>, "as" | "className"> & {
|
||||
as?: T;
|
||||
className?: string;
|
||||
}) {
|
||||
const 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
195
components/Resources.tsx
Normal file
195
components/Resources.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
type MotionValue,
|
||||
motion,
|
||||
useMotionTemplate,
|
||||
useMotionValue,
|
||||
} from "framer-motion";
|
||||
import Link from "next/link";
|
||||
|
||||
import type {
|
||||
ComponentPropsWithoutRef,
|
||||
ComponentType,
|
||||
MouseEvent,
|
||||
} from "react";
|
||||
import { GridPattern } from "./GridPattern";
|
||||
import { Heading } from "./Heading";
|
||||
import { ChatBubbleIcon } from "./icons/ChatBubbleIcon";
|
||||
import { EnvelopeIcon } from "./icons/EnvelopeIcon";
|
||||
import { UserIcon } from "./icons/UserIcon";
|
||||
import { UsersIcon } from "./icons/UsersIcon";
|
||||
|
||||
interface Resource {
|
||||
href: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
pattern: Omit<
|
||||
ComponentPropsWithoutRef<typeof GridPattern>,
|
||||
"width" | "height" | "x"
|
||||
>;
|
||||
}
|
||||
|
||||
const resources: Resource[] = [
|
||||
{
|
||||
href: "/contacts",
|
||||
name: "Contacts",
|
||||
description:
|
||||
"Learn about the contact model and how to create, retrieve, update, delete, and list contacts.",
|
||||
icon: UserIcon,
|
||||
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: ChatBubbleIcon,
|
||||
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: EnvelopeIcon,
|
||||
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: UsersIcon,
|
||||
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,
|
||||
}: 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 sm:grid-cols-2 xl:grid-cols-4 dark:border-white/5">
|
||||
{resources.map((resource) => (
|
||||
<Resource key={resource.href} resource={resource} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
511
components/Search.tsx
Normal file
511
components/Search.tsx
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
type AutocompleteApi,
|
||||
type AutocompleteCollection,
|
||||
type AutocompleteState,
|
||||
createAutocomplete,
|
||||
} from "@algolia/autocomplete-core";
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
Transition,
|
||||
TransitionChild,
|
||||
} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
type ElementRef,
|
||||
Fragment,
|
||||
type MouseEvent,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
Suspense,
|
||||
type SyntheticEvent,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import Highlighter from "react-highlight-words";
|
||||
|
||||
import type { Result } from "@/mdx/search.mjs";
|
||||
import { navigation } from "./Navigation";
|
||||
|
||||
type EmptyObject = Record<string, never>;
|
||||
|
||||
type Autocomplete = AutocompleteApi<
|
||||
Result,
|
||||
SyntheticEvent,
|
||||
MouseEvent,
|
||||
ReactKeyboardEvent
|
||||
>;
|
||||
|
||||
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,
|
||||
SyntheticEvent,
|
||||
MouseEvent,
|
||||
ReactKeyboardEvent
|
||||
>({
|
||||
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: 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: 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: 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={item}>
|
||||
<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<
|
||||
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}
|
||||
data-autofocus={true}
|
||||
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 sm:text-sm dark:text-white [&::-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<ElementRef<"form">>(null);
|
||||
const panelRef = useRef<ElementRef<"div">>(null);
|
||||
const inputRef = useRef<ElementRef<typeof SearchInput>>(null);
|
||||
const { autocomplete, autocompleteState } = useAutocomplete({
|
||||
close() {
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [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 show={open} afterLeave={() => autocomplete.setQuery("")}>
|
||||
<Dialog
|
||||
onClose={setOpen}
|
||||
className={clsx("fixed inset-0 z-50", className)}
|
||||
>
|
||||
<TransitionChild
|
||||
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" />
|
||||
</TransitionChild>
|
||||
|
||||
<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]">
|
||||
<TransitionChild
|
||||
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"
|
||||
>
|
||||
<DialogPanel className="mx-auto transform-gpu overflow-hidden rounded-lg bg-zinc-50 shadow-xl ring-1 ring-zinc-900/7.5 sm:max-w-xl dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<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>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
function useSearchProps() {
|
||||
const buttonRef = useRef<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);
|
||||
}
|
||||
}, []),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 lg:flex dark:bg-white/5 dark:text-zinc-400 dark:ring-inset dark:ring-white/10 dark:hover:ring-white/20"
|
||||
{...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 lg:hidden dark:hover:bg-white/5"
|
||||
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>
|
||||
);
|
||||
}
|
||||
165
components/SectionProvider.tsx
Normal file
165
components/SectionProvider.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type StoreApi, createStore, useStore } from "zustand";
|
||||
|
||||
import { remToPx } from "../lib/remToPx";
|
||||
|
||||
export interface Section {
|
||||
id: string;
|
||||
title: string;
|
||||
offsetRem?: number;
|
||||
tag?: string;
|
||||
headingRef?: RefObject<HTMLHeadingElement>;
|
||||
}
|
||||
|
||||
interface SectionState {
|
||||
sections: Section[];
|
||||
visibleSections: string[];
|
||||
setVisibleSections: (visibleSections: string[]) => void;
|
||||
registerHeading: ({
|
||||
id,
|
||||
ref,
|
||||
offsetRem,
|
||||
}: {
|
||||
id: string;
|
||||
ref: RefObject<HTMLHeadingElement>;
|
||||
offsetRem: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function createSectionStore(sections: 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: string[] = [];
|
||||
|
||||
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 ?? Number.POSITIVE_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: Section[];
|
||||
children: 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 as NonNullable<typeof store>, selector);
|
||||
}
|
||||
58
components/Tag.tsx
Normal file
58
components/Tag.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
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;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
<template>
|
||||
<div class="mt-20">
|
||||
<div class="max-w-3xl">
|
||||
<h1>Thank you!</h1>
|
||||
<p>
|
||||
The Lysand project is made possible by the hard work of our contributors. Here are some of the people
|
||||
who
|
||||
have helped make Lysand what it is today.
|
||||
</p>
|
||||
</div>
|
||||
<ul role="list"
|
||||
class="!mt-10 grid max-w-2xl grid-cols-1 gap-x-8 gap-y-16 sm:grid-cols-2 lg:max-w-none lg:grid-cols-3 !list-none !pl-0">
|
||||
<li v-for="person in people" :key="person.name"
|
||||
class="bg-[var(--vp-c-bg-soft)] shadow rounded duration-200 !m-0 hover:ring-2 hover:scale-[101%] ring-[var(--vp-color-primary)] p-4">
|
||||
<img class="aspect-[3/2] w-full rounded object-cover ring-1 ring-white/5" :src="person.imageUrl"
|
||||
:alt="`${person.name}'s avatar'`" />
|
||||
<h3 class="mt-6">{{ person.name }}</h3>
|
||||
<p class="!mt-3">
|
||||
<span v-for="role in person.roles"
|
||||
class="text-sm mr-2 last:mr-0 rounded bg-pink-700 text-pink-100 px-2 py-1">{{
|
||||
role }}</span>
|
||||
</p>
|
||||
<ul role="list" class="!mt-6 !flex !gap-6 !list-none !pl-0 flex-wrap">
|
||||
<li v-for="social in person.socials" :key="social.name" class="!m-0">
|
||||
<a :href="social.url" class="text-[var(--vp-color-primary)]" target="_blank" rel="noreferrer">
|
||||
<iconify-icon :icon="social.icon" class="text-2xl" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const people = [
|
||||
{
|
||||
name: "CPlusPatch",
|
||||
roles: ["Lead Developer", "UI Designer"],
|
||||
imageUrl: "https://avatars.githubusercontent.com/u/42910258?v=4",
|
||||
socials: [
|
||||
{
|
||||
name: "Website",
|
||||
icon: "bx:link",
|
||||
url: "https://cpluspatch.com",
|
||||
},
|
||||
{
|
||||
name: "GitHub",
|
||||
icon: "bxl:github",
|
||||
url: "https://github.com/cpluspatch",
|
||||
},
|
||||
{
|
||||
name: "Fediverse",
|
||||
icon: "bxl:mastodon",
|
||||
url: "https://mk.cpluspatch.com/@jessew",
|
||||
},
|
||||
{
|
||||
name: "Lysand",
|
||||
icon: "bx:server",
|
||||
url: "https://social.lysand.org/@jessew",
|
||||
},
|
||||
{
|
||||
name: "Matrix",
|
||||
icon: "simple-icons:matrix",
|
||||
url: "https://matrix.to/#/@jesse:cpluspatch.dev",
|
||||
},
|
||||
{
|
||||
name: "Signal",
|
||||
icon: "simple-icons:signal",
|
||||
url: "https://signal.me/#eu/mdX6iV0ayndNmJst43sNtlw3eFXgHSm7if4Y/mwYT1+qFDzl1PFAeroW+RpHGaRu",
|
||||
},
|
||||
{
|
||||
name: "Email",
|
||||
icon: "bx:bxs-envelope",
|
||||
url: "mailto:contact@cpluspatch.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "April",
|
||||
roles: ["ActivityPub Bridge Developer"],
|
||||
imageUrl: "https://avatars.githubusercontent.com/u/30842467?v=4",
|
||||
socials: [
|
||||
{
|
||||
name: "GitHub",
|
||||
icon: "bxl:github",
|
||||
url: "https://github.com/cutestnekoaqua",
|
||||
},
|
||||
{
|
||||
name: "Fediverse",
|
||||
icon: "bxl:mastodon",
|
||||
url: "https://donotsta.re/april",
|
||||
},
|
||||
{
|
||||
name: "Lysand",
|
||||
icon: "bx:server",
|
||||
url: "https://social.lysand.org/@aprl",
|
||||
},
|
||||
{
|
||||
name: "Matrix",
|
||||
icon: "simple-icons:matrix",
|
||||
url: "https://matrix.to/#/@aprl:uwu.is",
|
||||
},
|
||||
{
|
||||
name: "Email",
|
||||
icon: "bx:bxs-envelope",
|
||||
url: "mailto:aprl@acab.dev",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
46
components/ThemeToggle.tsx
Normal file
46
components/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { useTheme } from "next-themes";
|
||||
import { type ComponentPropsWithoutRef, useEffect, useState } from "react";
|
||||
|
||||
function SunIcon(props: 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: 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() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const otherTheme = resolvedTheme === "dark" ? "light" : "dark";
|
||||
const [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>
|
||||
);
|
||||
}
|
||||
19
components/icons/BellIcon.tsx
Normal file
19
components/icons/BellIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function BellIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.438 8.063a5.563 5.563 0 0 1 11.125 0v2.626c0 1.182.34 2.34.982 3.332L17.5 15.5h-15l.955-1.479c.641-.993.982-2.15.982-3.332V8.062Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 15.5v0a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v0"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
13
components/icons/BoltIcon.tsx
Normal file
13
components/icons/BoltIcon.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function BoltIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.5 11.5 10 2v5.5a1 1 0 0 0 1 1h4.5L10 18v-5.5a1 1 0 0 0-1-1H4.5Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
components/icons/BookIcon.tsx
Normal file
19
components/icons/BookIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function BookIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m10 5.5-7.5-3v12l7.5 3m0-12 7.5-3v12l-7.5 3m0-12v12"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m17.5 2.5-7.5 3v12l7.5-3v-12Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
25
components/icons/CalendarIcon.tsx
Normal file
25
components/icons/CalendarIcon.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function CalendarIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.5 6.5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-11a2 2 0 0 1-2-2v-9Z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.5 6.5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v2h-15v-2Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5.5 5.5v-3M14.5 5.5v-3"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
17
components/icons/CartIcon.tsx
Normal file
17
components/icons/CartIcon.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function CartIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeWidth="0"
|
||||
d="M5.98 11.288 3.5 5.5h14l-2.48 5.788A2 2 0 0 1 13.18 12.5H7.82a2 2 0 0 1-1.838-1.212Z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m3.5 5.5 2.48 5.788A2 2 0 0 0 7.82 12.5h5.362a2 2 0 0 0 1.839-1.212L17.5 5.5h-14Zm0 0-1-2M6.5 14.5a1 1 0 1 1 0 2 1 1 0 0 1 0-2ZM14.5 14.5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
components/icons/ChatBubbleIcon.tsx
Normal file
19
components/icons/ChatBubbleIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function ChatBubbleIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10 16.5c4.142 0 7.5-3.134 7.5-7s-3.358-7-7.5-7c-4.142 0-7.5 3.134-7.5 7 0 1.941.846 3.698 2.214 4.966L3.5 17.5c2.231 0 3.633-.553 4.513-1.248A8.014 8.014 0 0 0 10 16.5Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 8.5h5M8.5 11.5h3"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
components/icons/CheckIcon.tsx
Normal file
19
components/icons/CheckIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function CheckIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10 1.5a8.5 8.5 0 1 1 0 17 8.5 8.5 0 0 1 0-17Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m7.5 10.5 2 2c1-3.5 3-5 3-5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
components/icons/ChevronRightLeftIcon.tsx
Normal file
19
components/icons/ChevronRightLeftIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function ChevronRightLeftIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M1.5 10A6.5 6.5 0 0 1 8 3.5h4a6.5 6.5 0 1 1 0 13H8A6.5 6.5 0 0 1 1.5 10Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m7.5 7.5-3 2.5 3 2.5M12.5 7.5l3 2.5-3 2.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
components/icons/ClipboardIcon.tsx
Normal file
19
components/icons/ClipboardIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function ClipboardIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.5 6v10a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1l-.447.894A2 2 0 0 1 11.263 6H8.737a2 2 0 0 1-1.789-1.106L6.5 4h-1a2 2 0 0 0-2 2Z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m13.5 4-.447.894A2 2 0 0 1 11.263 6H8.737a2 2 0 0 1-1.789-1.106L6.5 4l.724-1.447A1 1 0 0 1 8.118 2h3.764a1 1 0 0 1 .894.553L13.5 4Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
21
components/icons/CogIcon.tsx
Normal file
21
components/icons/CogIcon.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function CogIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeWidth="0"
|
||||
fillRule="evenodd"
|
||||
d="M11.063 1.5H8.937l-.14 1.128c-.086.682-.61 1.22-1.246 1.484-.634.264-1.37.247-1.912-.175l-.898-.699-1.503 1.503.699.898c.422.543.44 1.278.175 1.912-.264.635-.802 1.16-1.484 1.245L1.5 8.938v2.124l1.128.142c.682.085 1.22.61 1.484 1.244.264.635.247 1.37-.175 1.913l-.699.898 1.503 1.503.898-.699c.543-.422 1.278-.44 1.912-.175.635.264 1.16.801 1.245 1.484l.142 1.128h2.124l.142-1.128c.085-.683.61-1.22 1.244-1.484.635-.264 1.37-.247 1.913.175l.898.699 1.503-1.503-.699-.898c-.422-.543-.44-1.278-.175-1.913.264-.634.801-1.16 1.484-1.245l1.128-.14V8.937l-1.128-.14c-.683-.086-1.22-.611-1.484-1.246-.264-.634-.247-1.37.175-1.912l.699-.898-1.503-1.503-.898.699c-.543.422-1.278.44-1.913.175-.634-.264-1.16-.802-1.244-1.484L11.062 1.5ZM10 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.938 1.5h2.124l.142 1.128c.085.682.61 1.22 1.244 1.484v0c.635.264 1.37.247 1.913-.175l.898-.699 1.503 1.503-.699.898c-.422.543-.44 1.278-.175 1.912v0c.264.635.801 1.16 1.484 1.245l1.128.142v2.124l-1.128.142c-.683.085-1.22.61-1.484 1.244v0c-.264.635-.247 1.37.175 1.913l.699.898-1.503 1.503-.898-.699c-.543-.422-1.278-.44-1.913-.175v0c-.634.264-1.16.801-1.245 1.484l-.14 1.128H8.937l-.14-1.128c-.086-.683-.611-1.22-1.246-1.484v0c-.634-.264-1.37-.247-1.912.175l-.898.699-1.503-1.503.699-.898c.422-.543.44-1.278.175-1.913v0c-.264-.634-.802-1.16-1.484-1.245l-1.128-.14V8.937l1.128-.14c.682-.086 1.22-.61 1.484-1.246v0c.264-.634.247-1.37-.175-1.912l-.699-.898 1.503-1.503.898.699c.543.422 1.278.44 1.912.175v0c.635-.264 1.16-.802 1.245-1.484L8.938 1.5Z"
|
||||
/>
|
||||
<circle cx="10" cy="10" r="2.5" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
components/icons/CopyIcon.tsx
Normal file
19
components/icons/CopyIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function CopyIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.5 5.5v-1a2 2 0 0 0-2-2h-8a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h1"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5.5 7.5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2v-8Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
components/icons/DocumentIcon.tsx
Normal file
19
components/icons/DocumentIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function DocumentIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.5 4.5v11a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-8h-5v-5h-6a2 2 0 0 0-2 2Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m11.5 2.5 5 5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
components/icons/EnvelopeIcon.tsx
Normal file
19
components/icons/EnvelopeIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function EnvelopeIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.5 5.5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v8a3 3 0 0 1-3 3h-9a3 3 0 0 1-3-3v-8Z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10 10 4.526 5.256c-.7-.607-.271-1.756.655-1.756h9.638c.926 0 1.355 1.15.655 1.756L10 10Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
components/icons/FaceSmileIcon.tsx
Normal file
19
components/icons/FaceSmileIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function FaceSmileIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10 1.5a8.5 8.5 0 1 1 0 17 8.5 8.5 0 0 1 0-17Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 6.5v2M12.5 6.5v2M5.5 11.5s1 3 4.5 3 4.5-3 4.5-3"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
24
components/icons/FolderIcon.tsx
Normal file
24
components/icons/FolderIcon.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function FolderIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17.5 15.5v-8a2 2 0 0 0-2-2h-2.93a2 2 0 0 1-1.664-.89l-.812-1.22A2 2 0 0 0 8.43 2.5H4.5a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2Z"
|
||||
/>
|
||||
<path
|
||||
strokeWidth="0"
|
||||
d="M8.43 2.5H4.5a2 2 0 0 0-2 2v1h9l-1.406-2.11A2 2 0 0 0 8.43 2.5Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m11.5 5.5-1.406-2.11A2 2 0 0 0 8.43 2.5H4.5a2 2 0 0 0-2 2v1h9Zm0 0h2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
14
components/icons/LinkIcon.tsx
Normal file
14
components/icons/LinkIcon.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function LinkIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m5.056 11.5-1.221-1.222a4.556 4.556 0 0 1 6.443-6.443L11.5 5.056M7.5 7.5l5 5m2.444-4 1.222 1.222a4.556 4.556 0 0 1-6.444 6.444L8.5 14.944"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
components/icons/ListIcon.tsx
Normal file
19
components/icons/ListIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function ListIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.5 4.5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-11a2 2 0 0 1-2-2v-11Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.5 6.5h7M6.5 13.5h7M6.5 10h7"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
15
components/icons/MagnifyingGlassIcon.tsx
Normal file
15
components/icons/MagnifyingGlassIcon.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function MagnifyingGlassIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path strokeWidth="0" d="M2.5 8.5a6 6 0 1 1 12 0 6 6 0 0 1-12 0Z" />
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m13 13 4.5 4.5m-9-3a6 6 0 1 1 0-12 6 6 0 0 1 0 12Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
21
components/icons/MapPinIcon.tsx
Normal file
21
components/icons/MapPinIcon.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function MapPinIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeWidth="0"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10 2.5A5.5 5.5 0 0 0 4.5 8c0 3.038 5.5 9.5 5.5 9.5s5.5-6.462 5.5-9.5A5.5 5.5 0 0 0 10 2.5Zm0 7a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.5 8a5.5 5.5 0 1 1 11 0c0 3.038-5.5 9.5-5.5 9.5S4.5 11.038 4.5 8Z"
|
||||
/>
|
||||
<circle cx="10" cy="8" r="1.5" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
components/icons/PackageIcon.tsx
Normal file
18
components/icons/PackageIcon.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function PackageIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeWidth="0"
|
||||
d="m10 9.5-7.5-4v9l7.5 4v-9ZM10 9.5l7.5-4v9l-7.5 4v-9Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m2.5 5.5 7.5 4m-7.5-4v9l7.5 4m-7.5-13 7.5-4 7.5 4m-7.5 4v9m0-9 7.5-4m-7.5 13 7.5-4v-9m-11 6 .028-3.852L13.5 3.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
components/icons/PaperAirplaneIcon.tsx
Normal file
19
components/icons/PaperAirplaneIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function PaperAirplaneIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17 3L1 9L8 12M17 3L11 19L8 12M17 3L8 12"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M11 19L8 12L17 3L11 19Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
14
components/icons/PaperClipIcon.tsx
Normal file
14
components/icons/PaperClipIcon.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function PaperClipIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m15.56 7.375-3.678-3.447c-2.032-1.904-5.326-1.904-7.358 0s-2.032 4.99 0 6.895l6.017 5.639c1.477 1.384 3.873 1.384 5.35 0 1.478-1.385 1.478-3.63 0-5.015L10.21 6.122a1.983 1.983 0 0 0-2.676 0 1.695 1.695 0 0 0 0 2.507l4.013 3.76"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
components/icons/ShapesIcon.tsx
Normal file
19
components/icons/ShapesIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function ShapesIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.5 7.5v-4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1ZM11.5 16.5v-4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1Z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m2.5 17.5 3-6 3 6h-6ZM14.5 2.5a3 3 0 1 1 0 6 3 3 0 0 1 0-6Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
13
components/icons/ShirtIcon.tsx
Normal file
13
components/icons/ShirtIcon.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function ShirtIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12.5 1.5s0 2-2.5 2-2.5-2-2.5-2h-2L2.207 4.793a1 1 0 0 0 0 1.414L4.5 8.5v10h11v-10l2.293-2.293a1 1 0 0 0 0-1.414L14.5 1.5h-2Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
components/icons/SquaresPlusIcon.tsx
Normal file
19
components/icons/SquaresPlusIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function SquaresPlusIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.5 4.5v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2ZM8.5 13.5v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2ZM17.5 4.5v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.5 11.5v6M17.5 14.5h-6"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
21
components/icons/TagIcon.tsx
Normal file
21
components/icons/TagIcon.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function TagIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeWidth="0"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M3 8.69499V3H8.69499C9.18447 3 9.65389 3.19444 10 3.54055L16.4594 10C17.1802 10.7207 17.1802 11.8893 16.4594 12.61L12.61 16.4594C11.8893 17.1802 10.7207 17.1802 10 16.4594L3.54055 10C3.19444 9.65389 3 9.18447 3 8.69499ZM7 8.5C7.82843 8.5 8.5 7.82843 8.5 7C8.5 6.17157 7.82843 5.5 7 5.5C6.17157 5.5 5.5 6.17157 5.5 7C5.5 7.82843 6.17157 8.5 7 8.5Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 3V8.69499C3 9.18447 3.19444 9.65389 3.54055 10L10 16.4594C10.7207 17.1802 11.8893 17.1802 12.61 16.4594L16.4594 12.61C17.1802 11.8893 17.1802 10.7207 16.4594 10L10 3.54055C9.65389 3.19444 9.18447 3 8.69499 3H3Z"
|
||||
/>
|
||||
<circle cx="7" cy="7" r="1.5" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
26
components/icons/UserIcon.tsx
Normal file
26
components/icons/UserIcon.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function UserIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeWidth="0"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10 .5a9.5 9.5 0 0 1 5.598 17.177C14.466 15.177 12.383 13.5 10 13.5s-4.466 1.677-5.598 4.177A9.5 9.5 0 0 1 10 .5ZM12.5 8a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10 .5a9.5 9.5 0 0 1 5.598 17.177A9.458 9.458 0 0 1 10 19.5a9.458 9.458 0 0 1-5.598-1.823A9.5 9.5 0 0 1 10 .5Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.402 17.677C5.534 15.177 7.617 13.5 10 13.5s4.466 1.677 5.598 4.177M10 5.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
30
components/icons/UsersIcon.tsx
Normal file
30
components/icons/UsersIcon.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export function UsersIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.046 16H1.955a.458.458 0 0 1-.455-.459C1.5 13.056 3.515 11 6 11h.5"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 15.454C7.5 12.442 9.988 10 13 10s5.5 2.442 5.5 5.454a.545.545 0 0 1-.546.546H8.045a.545.545 0 0 1-.545-.546Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.5 4a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13 2a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
126
components/mdx.tsx
Normal file
126
components/mdx.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import { Feedback } from "./Feedback";
|
||||
import { Heading } from "./Heading";
|
||||
import { Prose } from "./Prose";
|
||||
|
||||
export const a = Link;
|
||||
// biome-ignore lint/performance/noBarrelFile: <explanation>
|
||||
export { Button } from "./Button";
|
||||
export { CodeGroup, Code as code, Pre as pre } from "./Code";
|
||||
|
||||
export function wrapper({ children }: { children: 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<ComponentPropsWithoutRef<typeof Heading>, "level">,
|
||||
) {
|
||||
return <Heading level={2} {...props} />;
|
||||
};
|
||||
|
||||
function InfoIcon(props: 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 Note({ children }: { children: 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: 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: 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: ReactNode }) {
|
||||
return (
|
||||
<div className="my-6">
|
||||
<ul 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: 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue