mirror of
https://github.com/fosrl/docs-v2.git
synced 2026-09-27 00:09:07 +02:00
port mintlify to fumadocs
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { useAISearchContext, useAskAI } from './search';
|
||||
|
||||
/**
|
||||
* Mintlify-style "Ask a question..." bar that sticks to the bottom of the page content.
|
||||
* Submitting opens the chat panel with the question; it hides while the panel is open.
|
||||
*/
|
||||
export function AskBar() {
|
||||
const { open } = useAISearchContext();
|
||||
const ask = useAskAI();
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
function onSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const text = value.trim();
|
||||
if (!text) return;
|
||||
ask(text);
|
||||
setValue('');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('pg-askbar-wrap', open && 'pg-askbar-hidden')} aria-hidden={open}>
|
||||
<form onSubmit={onSubmit} className="pg-chat-input pg-askbar">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Ask a question..."
|
||||
aria-label="Ask AI about the docs"
|
||||
tabIndex={open ? -1 : undefined}
|
||||
/>
|
||||
<kbd className="pg-askbar-kbd">⌘I</kbd>
|
||||
<button
|
||||
type="submit"
|
||||
className="pg-chat-send"
|
||||
aria-label="Send"
|
||||
disabled={value.trim().length === 0}
|
||||
tabIndex={open ? -1 : undefined}
|
||||
>
|
||||
<ArrowUp className="size-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { useAskAI } from './search';
|
||||
|
||||
export function AskAIAboutPage({ title }: { title: string }) {
|
||||
const ask = useAskAI();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonVariants({ color: 'secondary', size: 'sm' }),
|
||||
'gap-2 [&_svg]:size-3.5 [&_svg]:text-fd-muted-foreground',
|
||||
)}
|
||||
onClick={() => ask(`Summarize the "${title}" page I'm on and what I should do next.`)}
|
||||
>
|
||||
<Sparkles />
|
||||
Ask AI
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
'use client';
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
type SyntheticEvent,
|
||||
use,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { ArrowUp, FileText, Loader2, RefreshCw, SearchIcon, Sparkles, Square, X } from 'lucide-react';
|
||||
import { cn } from '../../lib/cn';
|
||||
import { buttonVariants } from '../ui/button';
|
||||
import { useChat, type UseChatHelpers } from '@ai-sdk/react';
|
||||
import { DefaultChatTransport, type UIMessage } from 'ai';
|
||||
import { Markdown } from '../markdown';
|
||||
|
||||
export type ChatUIMessage = UIMessage<
|
||||
never,
|
||||
{
|
||||
client: {
|
||||
location: string;
|
||||
};
|
||||
}
|
||||
>;
|
||||
|
||||
|
||||
const Context = createContext<{
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
chat: UseChatHelpers<ChatUIMessage>;
|
||||
} | null>(null);
|
||||
|
||||
export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div'>) {
|
||||
const { setOpen } = useAISearchContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 flex items-start gap-2 border rounded-xl bg-fd-secondary text-fd-secondary-foreground shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="px-3 py-2 flex-1">
|
||||
<p className="text-sm font-medium mb-1 flex items-center gap-1.5">
|
||||
<Sparkles className="size-3.5 text-fd-primary" />
|
||||
Ask Pangolin AI
|
||||
</p>
|
||||
<p className="text-xs text-fd-muted-foreground">
|
||||
Answers are generated from the docs and can be wrong. Check the linked pages.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
aria-label="Close"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: 'icon-sm',
|
||||
variant: 'ghost',
|
||||
className: 'text-fd-muted-foreground rounded-full',
|
||||
}),
|
||||
)}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AISearchInputActions() {
|
||||
const { messages, status, setMessages, regenerate } = useChatContext();
|
||||
const isLoading = status === 'streaming';
|
||||
|
||||
if (messages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isLoading && messages.at(-1)?.role === 'assistant' && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'secondary',
|
||||
size: 'sm',
|
||||
className: 'rounded-full gap-1.5',
|
||||
}),
|
||||
)}
|
||||
onClick={() => regenerate()}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'secondary',
|
||||
size: 'sm',
|
||||
className: 'rounded-full',
|
||||
}),
|
||||
)}
|
||||
onClick={() => setMessages([])}
|
||||
>
|
||||
Clear Chat
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const suggestions = [
|
||||
'How do I expose a web app running on my home server?',
|
||||
'What is the difference between a site and a client?',
|
||||
'How do I self-host Pangolin with Docker Compose?',
|
||||
'How do I connect Claude Code to the AI Gateway?',
|
||||
];
|
||||
|
||||
function sendText(send: UseChatHelpers<ChatUIMessage>['sendMessage'], text: string) {
|
||||
void send({
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ type: 'data-client', data: { location: location.href } },
|
||||
{ type: 'text', text },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** open the panel and immediately ask `text` */
|
||||
export function useAskAI() {
|
||||
const { setOpen, chat } = useAISearchContext();
|
||||
return (text: string) => {
|
||||
setOpen(true);
|
||||
if (chat.status === 'streaming' || chat.status === 'submitted') return;
|
||||
sendText(chat.sendMessage, text);
|
||||
};
|
||||
}
|
||||
|
||||
const StorageKeyInput = '__ai_search_input';
|
||||
export function AISearchInput(props: ComponentProps<'form'>) {
|
||||
const { status, sendMessage, stop } = useChatContext();
|
||||
const [input, setInput] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(StorageKeyInput) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
const isLoading = status === 'streaming' || status === 'submitted';
|
||||
const onStart = (e?: SyntheticEvent) => {
|
||||
e?.preventDefault();
|
||||
const message = input.trim();
|
||||
if (message.length === 0) return;
|
||||
|
||||
sendText(sendMessage, message);
|
||||
setInput('');
|
||||
try {
|
||||
localStorage.removeItem(StorageKeyInput);
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) document.getElementById('nd-ai-input')?.focus();
|
||||
}, [isLoading]);
|
||||
|
||||
return (
|
||||
<form {...props} className={cn('flex items-end gap-2 p-2', props.className)} onSubmit={onStart}>
|
||||
<Input
|
||||
value={input}
|
||||
placeholder={isLoading ? 'Pangolin AI is answering…' : 'Ask a question...'}
|
||||
autoFocus
|
||||
className="px-2 py-1.5 text-sm"
|
||||
disabled={status === 'streaming' || status === 'submitted'}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
try {
|
||||
localStorage.setItem(StorageKeyInput, e.target.value);
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
// keyCode 229: Safari fires `compositionend` before this keydown, `isComposing` is already false
|
||||
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
|
||||
if (!event.shiftKey && event.key === 'Enter') {
|
||||
onStart(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<button key="bn" type="button" className="pg-chat-send" aria-label="Stop answering" onClick={stop}>
|
||||
<Square className="size-3 fill-current" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
key="bn"
|
||||
type="submit"
|
||||
className="pg-chat-send"
|
||||
aria-label="Send"
|
||||
disabled={input.trim().length === 0}
|
||||
>
|
||||
<ArrowUp className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function List(props: Omit<ComponentProps<'div'>, 'dir'>) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
function callback() {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: 'instant',
|
||||
});
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(callback);
|
||||
callback();
|
||||
|
||||
const element = containerRef.current?.firstElementChild;
|
||||
|
||||
if (element) {
|
||||
observer.observe(element);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
{...props}
|
||||
className={cn('fd-scroll-container overflow-y-auto min-w-0 flex flex-col', props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Input(props: ComponentProps<'textarea'>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const shared = cn('pg-chat-field col-start-1 row-start-1', props.className);
|
||||
|
||||
return (
|
||||
<div className="grid flex-1">
|
||||
<textarea
|
||||
id="nd-ai-input"
|
||||
{...props}
|
||||
className={cn(
|
||||
'resize-none bg-transparent placeholder:text-fd-muted-foreground focus-visible:outline-none',
|
||||
shared,
|
||||
)}
|
||||
/>
|
||||
<div ref={ref} className={cn(shared, 'break-all invisible')}>
|
||||
{`${props.value?.toString() ?? ''}\n`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const roleName: Record<string, string> = {
|
||||
user: 'You',
|
||||
assistant: 'Pangolin AI',
|
||||
};
|
||||
|
||||
interface ToolPart {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
state: string;
|
||||
input?: { query?: string; path?: string };
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
}
|
||||
|
||||
function ToolActivity({ part }: { part: ToolPart }) {
|
||||
const name = part.type.slice('tool-'.length);
|
||||
const failed = part.state === 'output-error' || part.state === 'output-denied';
|
||||
const done = part.state === 'output-available';
|
||||
|
||||
let label: ReactNode;
|
||||
if (name === 'search_docs') {
|
||||
const count = Array.isArray(part.output) ? part.output.length : 0;
|
||||
label = done ? (
|
||||
<>
|
||||
Searched <q>{part.input?.query}</q>, {count} {count === 1 ? 'page' : 'pages'}
|
||||
</>
|
||||
) : (
|
||||
<>Searching <q>{part.input?.query ?? '…'}</q></>
|
||||
);
|
||||
} else if (name === 'read_page') {
|
||||
label = <>{done ? 'Read' : 'Reading'} <code>{part.input?.path ?? '…'}</code></>;
|
||||
} else {
|
||||
label = name;
|
||||
}
|
||||
|
||||
const Icon = name === 'read_page' ? FileText : SearchIcon;
|
||||
return (
|
||||
<div className="flex flex-row gap-2 items-center rounded-lg border bg-fd-secondary text-fd-muted-foreground text-xs px-2 py-1.5 min-w-0">
|
||||
{done || failed ? <Icon className="size-3.5 shrink-0" /> : <Loader2 className="size-3.5 shrink-0 animate-spin" />}
|
||||
{failed ? (
|
||||
<p className="text-fd-error truncate">{part.errorText ?? 'Tool call failed'}</p>
|
||||
) : (
|
||||
<p className="truncate">{label}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Message({ message, ...props }: { message: ChatUIMessage } & ComponentProps<'div'>) {
|
||||
let markdown = '';
|
||||
const toolCalls: ToolPart[] = [];
|
||||
|
||||
for (const part of message.parts ?? []) {
|
||||
if (part.type === 'text') {
|
||||
markdown += part.text;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.type.startsWith('tool-')) {
|
||||
const p = part as unknown as ToolPart;
|
||||
if (p.toolCallId) toolCalls.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()} {...props}>
|
||||
<p
|
||||
className={cn(
|
||||
'mb-1 text-sm font-medium text-fd-muted-foreground',
|
||||
message.role === 'assistant' && 'text-fd-primary',
|
||||
)}
|
||||
>
|
||||
{roleName[message.role] ?? 'unknown'}
|
||||
</p>
|
||||
{toolCalls.length > 0 && (
|
||||
<div className="flex flex-col gap-1 mb-2">
|
||||
{toolCalls.map((call) => (
|
||||
<ToolActivity key={call.toolCallId} part={call} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="prose text-sm">
|
||||
<Markdown text={markdown} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AISearch({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const chat = useChat<ChatUIMessage>({
|
||||
id: 'search',
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<Context value={useMemo(() => ({ chat, open, setOpen }), [chat, open])}>{children}</Context>
|
||||
);
|
||||
}
|
||||
|
||||
export function AISearchTrigger({
|
||||
position = 'default',
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<'button'> & { position?: 'default' | 'float' }) {
|
||||
const { open, setOpen } = useAISearchContext();
|
||||
|
||||
return (
|
||||
<button
|
||||
data-state={open ? 'open' : 'closed'}
|
||||
className={cn(
|
||||
position === 'float' && [
|
||||
'fixed bottom-4 gap-2 inset-e-[calc(--spacing(4)+var(--removed-body-scroll-bar-size,0px))] shadow-lg z-20 transition-[translate,opacity]',
|
||||
open && 'translate-y-10 opacity-0',
|
||||
],
|
||||
className,
|
||||
)}
|
||||
onClick={() => setOpen(!open)}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AISearchPanel() {
|
||||
const { open, setOpen } = useAISearchContext();
|
||||
const [actualOpen, setActualOpen] = useState(open);
|
||||
useHotKey();
|
||||
|
||||
if (open && !actualOpen) setActualOpen(open);
|
||||
|
||||
// The docs grid reserves a column for the panel while this attribute is set (see
|
||||
// `--pg-panel` in app/global.css). It follows `open` directly, so the content moves
|
||||
// back as soon as the panel starts closing, whatever happens to the close animation.
|
||||
useEffect(() => {
|
||||
const layout = document.getElementById('nd-notebook-layout');
|
||||
layout?.toggleAttribute('data-ai-open', open);
|
||||
return () => layout?.removeAttribute('data-ai-open');
|
||||
}, [open]);
|
||||
|
||||
// Unmount after the close animation. `animationend` never fires when animations are
|
||||
// skipped (background tab, reduced motion), so also fall back to a timer.
|
||||
useEffect(() => {
|
||||
if (open || !actualOpen) return;
|
||||
const timer = window.setTimeout(() => setActualOpen(false), 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open, actualOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
@keyframes ask-ai-open {
|
||||
from {
|
||||
translate: 100% 0;
|
||||
}
|
||||
to {
|
||||
translate: 0 0;
|
||||
}
|
||||
}
|
||||
@keyframes ask-ai-close {
|
||||
from {
|
||||
width: var(--ai-chat-width);
|
||||
}
|
||||
to {
|
||||
width: 0px;
|
||||
}
|
||||
}`}
|
||||
</style>
|
||||
{actualOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-30 backdrop-blur-xs bg-fd-overlay lg:hidden',
|
||||
open ? 'animate-fd-fade-in' : 'animate-fd-fade-out',
|
||||
)}
|
||||
onClick={() => setOpen(false)}
|
||||
onAnimationEnd={() => {
|
||||
if (!open) flushSync(() => setActualOpen(false));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{actualOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'pg-ai-panel overflow-hidden z-30 bg-fd-card text-fd-card-foreground [--ai-chat-width:400px] 2xl:[--ai-chat-width:460px]',
|
||||
'max-lg:fixed max-lg:inset-x-2 max-lg:inset-y-4 max-lg:border max-lg:rounded-2xl max-lg:shadow-xl',
|
||||
'lg:sticky lg:top-(--fd-docs-row-2) lg:h-[calc(100dvh-var(--fd-docs-row-2))] lg:border-s lg:ms-auto lg:in-[#nd-notebook-layout]:[grid-area:2/5/4/6]',
|
||||
open
|
||||
? 'animate-fd-dialog-in lg:animate-[ask-ai-open_200ms]'
|
||||
: 'animate-fd-dialog-out lg:animate-[ask-ai-close_200ms]',
|
||||
)}
|
||||
onAnimationEnd={() => {
|
||||
if (!open) flushSync(() => setActualOpen(false));
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col size-full p-2 lg:p-3 lg:w-(--ai-chat-width)">
|
||||
<AISearchPanelHeader />
|
||||
<AISearchPanelList className="flex-1" />
|
||||
<div className="pg-chat-input">
|
||||
<AISearchInput />
|
||||
<div className="flex items-center gap-1.5 px-2 pb-2 empty:hidden">
|
||||
<AISearchInputActions />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AISearchPanelList({ className, style, ...props }: ComponentProps<'div'>) {
|
||||
const chat = useChatContext();
|
||||
const messages = chat.messages.filter((msg) => msg.role !== 'system');
|
||||
|
||||
return (
|
||||
<List
|
||||
className={cn('py-4 overscroll-contain', className)}
|
||||
style={{
|
||||
maskImage:
|
||||
'linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 1rem), transparent 100%)',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div
|
||||
className="size-full flex flex-col justify-end gap-2 px-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="text-sm text-fd-muted-foreground mb-1">
|
||||
Ask anything about Pangolin. The assistant searches and reads these docs to answer.
|
||||
</p>
|
||||
{suggestions.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
type="button"
|
||||
className="text-start text-sm rounded-xl border bg-fd-card px-3 py-2 text-fd-foreground transition-colors hover:bg-fd-accent"
|
||||
onClick={() => sendText(chat.sendMessage, q)}
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col px-3 gap-4">
|
||||
{messages.map((item) => (
|
||||
<Message key={item.id} message={item} />
|
||||
))}
|
||||
{chat.error && (
|
||||
<div className="p-2 bg-fd-secondary text-fd-secondary-foreground border rounded-lg">
|
||||
<p className="text-xs text-fd-muted-foreground mb-1">Request failed</p>
|
||||
<p className="text-sm">{errorText(chat.error)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
/** `/api/chat` returns `{ error }` JSON for config / rate-limit errors */
|
||||
function errorText(error: Error) {
|
||||
try {
|
||||
const parsed = JSON.parse(error.message) as { error?: string };
|
||||
if (parsed.error) return parsed.error;
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
|
||||
export function useHotKey() {
|
||||
const { open, setOpen } = useAISearchContext();
|
||||
|
||||
const onKeyPress = useEffectEvent((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && open) {
|
||||
setOpen(false);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if ((e.key === '/' || e.key === 'i') && (e.metaKey || e.ctrlKey) && !open) {
|
||||
setOpen(true);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', onKeyPress);
|
||||
return () => window.removeEventListener('keydown', onKeyPress);
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useAISearchContext() {
|
||||
return use(Context)!;
|
||||
}
|
||||
|
||||
function useChatContext() {
|
||||
return use(Context)!.chat;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Script from 'next/script';
|
||||
|
||||
/**
|
||||
* Same analytics as the Mintlify site (PostHog via the pangolin.net relay, Rybbit, Reo).
|
||||
* Only loaded in production builds; set NEXT_PUBLIC_DISABLE_ANALYTICS=1 to turn off.
|
||||
*/
|
||||
const POSTHOG_KEY = 'phc_RIHQ7o2Y2hf8qms2nP62vpoJHEvsrw6TieflQGQO7yI';
|
||||
const POSTHOG_HOST = 'https://pangolin.net/relay-O7yI';
|
||||
const RYBBIT_SITE_ID = 'da4fb64dc2d5';
|
||||
const REO_CLIENT_ID = '4209f8e1b88e13b';
|
||||
|
||||
export function Analytics() {
|
||||
if (process.env.NODE_ENV !== 'production' || process.env.NEXT_PUBLIC_DISABLE_ANALYTICS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script id="posthog" strategy="afterInteractive">
|
||||
{`!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey getNextSurveyStep identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
|
||||
posthog.init(${JSON.stringify(POSTHOG_KEY)},{api_host:${JSON.stringify(POSTHOG_HOST)},ui_host:'https://us.posthog.com',person_profiles:'identified_only'});`}
|
||||
</Script>
|
||||
<Script
|
||||
src="https://rybbit.fossorial.io/api/script.js"
|
||||
data-site-id={RYBBIT_SITE_ID}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<Script
|
||||
src={`https://static.reo.dev/${REO_CLIENT_ID}/reo.js`}
|
||||
strategy="afterInteractive"
|
||||
id="reo"
|
||||
/>
|
||||
<Script id="reo-init" strategy="lazyOnload">
|
||||
{`(function w(){if(window.Reo){Reo.init({clientID:${JSON.stringify(REO_CLIENT_ID)}})}else setTimeout(w,500)})();`}
|
||||
</Script>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import {
|
||||
FullSearchTrigger,
|
||||
SearchTrigger,
|
||||
type FullSearchTriggerProps,
|
||||
type SearchTriggerProps,
|
||||
} from 'fumadocs-ui/layouts/shared/slots/search-trigger';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { useAISearchContext } from './ai/search';
|
||||
|
||||
/**
|
||||
* Header search slot: the normal search bar with an "Ask AI" button to its right, like
|
||||
* Mintlify. The layout passes the search bar's sizing classes; they go on the wrapper.
|
||||
*/
|
||||
function HeaderSearchFull({ className, ...props }: FullSearchTriggerProps) {
|
||||
const { open, setOpen } = useAISearchContext();
|
||||
|
||||
return (
|
||||
<div className={cn(className, 'flex items-center gap-2 ps-0 max-w-md')}>
|
||||
<FullSearchTrigger {...props} className="ps-2.5 rounded-xl flex-1 min-w-0 h-9" />
|
||||
<button
|
||||
type="button"
|
||||
className="pg-ask-ai-btn"
|
||||
data-state={open ? 'open' : 'closed'}
|
||||
aria-pressed={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<Sparkles />
|
||||
Ask AI
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderSearchSm(props: SearchTriggerProps) {
|
||||
return <SearchTrigger {...props} />;
|
||||
}
|
||||
|
||||
export const headerSearchSlot = { full: HeaderSearchFull, sm: HeaderSearchSm };
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { SVGProps } from 'react';
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
Bolt,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Box,
|
||||
Boxes,
|
||||
Brain,
|
||||
Brush,
|
||||
Building2,
|
||||
ChartColumn,
|
||||
ChartLine,
|
||||
Check,
|
||||
CircleHelp,
|
||||
Cloud,
|
||||
CreditCard,
|
||||
Database,
|
||||
Download,
|
||||
FileCode,
|
||||
GitBranch,
|
||||
Globe,
|
||||
Hash,
|
||||
IdCard,
|
||||
Key,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Link,
|
||||
ListChecks,
|
||||
Lock,
|
||||
Mail,
|
||||
Monitor,
|
||||
Network,
|
||||
PaintBucket,
|
||||
Plug,
|
||||
Route,
|
||||
Server,
|
||||
Shield,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
TableProperties,
|
||||
Terminal,
|
||||
User,
|
||||
Users,
|
||||
Waypoints,
|
||||
Workflow,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
type IconComponent = LucideIcon | ((props: SVGProps<SVGSVGElement>) => React.ReactElement);
|
||||
|
||||
// Brand icons are not part of lucide, so they are inlined here.
|
||||
export function GitHubIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
|
||||
<path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 0-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.2.5-2.3 1.3-3.1-.2-.4-.6-1.6 0-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.6 1.6.2 2.8 0 3.2.9.8 1.3 1.9 1.3 3.2 0 4.6-2.8 5.6-5.5 5.9.5.4.9 1 .9 2.2v3.3c0 .3.1.7.8.6A12 12 0 0 0 12 .3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
|
||||
<path d="M20.3 4.4A19.8 19.8 0 0 0 15.4 3l-.6 1.3a18.3 18.3 0 0 0-5.5 0L8.6 3a19.7 19.7 0 0 0-4.9 1.5A20.3 20.3 0 0 0 .1 18.1a19.9 19.9 0 0 0 6 3l1.3-2a12.9 12.9 0 0 1-2-1l.5-.4a14.2 14.2 0 0 0 12.2 0l.5.4-2 1 1.3 2a19.8 19.8 0 0 0 6-3 20.2 20.2 0 0 0-3.6-13.7ZM8 15.4c-1.2 0-2.2-1.1-2.2-2.4S6.8 10.5 8 10.5s2.2 1.1 2.2 2.5c0 1.3-1 2.4-2.2 2.4Zm8 0c-1.2 0-2.2-1.1-2.2-2.4s1-2.5 2.2-2.5 2.2 1.1 2.2 2.5c0 1.3-1 2.4-2.2 2.4Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SlackIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
|
||||
<path d="M5 15.2a2.5 2.5 0 1 1-2.5-2.5H5v2.5Zm1.3 0a2.5 2.5 0 0 1 5 0v6.3a2.5 2.5 0 1 1-5 0v-6.3ZM8.8 5a2.5 2.5 0 1 1 2.5-2.5V5H8.8Zm0 1.3a2.5 2.5 0 0 1 0 5H2.5a2.5 2.5 0 1 1 0-5h6.3ZM19 8.8a2.5 2.5 0 1 1 2.5 2.5H19V8.8Zm-1.3 0a2.5 2.5 0 0 1-5 0V2.5a2.5 2.5 0 1 1 5 0v6.3ZM15.2 19a2.5 2.5 0 1 1-2.5 2.5V19h2.5Zm0-1.3a2.5 2.5 0 0 1 0-5h6.3a2.5 2.5 0 1 1 0 5h-6.3Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinkedInIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
|
||||
<path d="M20.4 20.5h-3.6v-5.6c0-1.3 0-3-1.8-3s-2.1 1.4-2.1 2.9v5.7H9.3V9h3.4v1.6c.5-.9 1.6-1.8 3.4-1.8 3.6 0 4.3 2.4 4.3 5.5v6.2ZM5.3 7.4a2.1 2.1 0 1 1 0-4.1 2.1 2.1 0 0 1 0 4.1Zm1.8 13.1H3.6V9h3.5v11.5ZM22.2 0H1.8C.8 0 0 .8 0 1.7v20.6c0 .9.8 1.7 1.8 1.7h20.4c1 0 1.8-.8 1.8-1.7V1.7C24 .8 23.2 0 22.2 0Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function YouTubeIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
|
||||
<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.6 12 3.6 12 3.6s-7.5 0-9.4.5A3 3 0 0 0 .5 6.2 31.4 31.4 0 0 0 0 12a31.4 31.4 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.5 9.4.5 9.4.5s7.5 0 9.4-.5a3 3 0 0 0 2.1-2.1A31.4 31.4 0 0 0 24 12a31.4 31.4 0 0 0-.5-5.8ZM9.6 15.6V8.4l6.2 3.6-6.2 3.6Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppleIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
|
||||
<path d="M16.4 12.6c0-2.3 1.9-3.4 2-3.5-1.1-1.6-2.8-1.8-3.4-1.8-1.4-.2-2.8.8-3.5.8s-1.8-.8-3-.8c-1.5 0-3 .9-3.8 2.3-1.6 2.8-.4 7 1.2 9.3.8 1.1 1.7 2.3 2.9 2.3 1.2 0 1.6-.7 3-.7s1.8.7 3 .7 2-1.1 2.7-2.2c.9-1.2 1.2-2.4 1.2-2.5-.1 0-2.3-.9-2.3-3.9ZM14.6 5.9c.6-.8 1.1-1.8.9-2.9-1 .1-2.1.6-2.8 1.4-.6.7-1.2 1.7-1 2.7 1.1.1 2.2-.5 2.9-1.2Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function WindowsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
|
||||
<path d="M3 5.2 10.6 4.1v7.1H3V5.2Zm8.4-1.2L21 2.6v8.6h-9.6V4Zm-8.4 8.2h7.6v7.2L3 18.3v-6.1Zm8.4 0H21v8.8l-9.6-1.3v-7.5Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinuxIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 304.998 304.998" fill="currentColor" aria-hidden {...props}>
|
||||
<path d="M274.659,244.888c-8.944-3.663-12.77-8.524-12.4-15.777c0.381-8.466-4.422-14.667-6.703-17.117 c1.378-5.264,5.405-23.474,0.004-39.291c-5.804-16.93-23.524-42.787-41.808-68.204c-7.485-10.438-7.839-21.784-8.248-34.922 c-0.392-12.531-0.834-26.735-7.822-42.525C190.084,9.859,174.838,0,155.851,0c-11.295,0-22.889,3.53-31.811,9.684 c-18.27,12.609-15.855,40.1-14.257,58.291c0.219,2.491,0.425,4.844,0.545,6.853c1.064,17.816,0.096,27.206-1.17,30.06 c-0.819,1.865-4.851,7.173-9.118,12.793c-4.413,5.812-9.416,12.4-13.517,18.539c-4.893,7.387-8.843,18.678-12.663,29.597 c-2.795,7.99-5.435,15.537-8.005,20.047c-4.871,8.676-3.659,16.766-2.647,20.505c-1.844,1.281-4.508,3.803-6.757,8.557 c-2.718,5.8-8.233,8.917-19.701,11.122c-5.27,1.078-8.904,3.294-10.804,6.586c-2.765,4.791-1.259,10.811,0.115,14.925 c2.03,6.048,0.765,9.876-1.535,16.826c-0.53,1.604-1.131,3.42-1.74,5.423c-0.959,3.161-0.613,6.035,1.026,8.542 c4.331,6.621,16.969,8.956,29.979,10.492c7.768,0.922,16.27,4.029,24.493,7.035c8.057,2.944,16.388,5.989,23.961,6.913 c1.151,0.145,2.291,0.218,3.39,0.218c11.434,0,16.6-7.587,18.238-10.704c4.107-0.838,18.272-3.522,32.871-3.882 c14.576-0.416,28.679,2.462,32.674,3.357c1.256,2.404,4.567,7.895,9.845,10.724c2.901,1.586,6.938,2.495,11.073,2.495 c0.001,0,0,0,0.001,0c4.416,0,12.817-1.044,19.466-8.039c6.632-7.028,23.202-16,35.302-22.551c2.7-1.462,5.226-2.83,7.441-4.065 c6.797-3.768,10.506-9.152,10.175-14.771C282.445,250.905,279.356,246.811,274.659,244.888z M124.189,243.535 c-0.846-5.96-8.513-11.871-17.392-18.715c-7.26-5.597-15.489-11.94-17.756-17.312c-4.685-11.082-0.992-30.568,5.447-40.602 c3.182-5.024,5.781-12.643,8.295-20.011c2.714-7.956,5.521-16.182,8.66-19.783c4.971-5.622,9.565-16.561,10.379-25.182 c4.655,4.444,11.876,10.083,18.547,10.083c1.027,0,2.024-0.134,2.977-0.403c4.564-1.318,11.277-5.197,17.769-8.947 c5.597-3.234,12.499-7.222,15.096-7.585c4.453,6.394,30.328,63.655,32.972,82.044c2.092,14.55-0.118,26.578-1.229,31.289 c-0.894-0.122-1.96-0.221-3.08-0.221c-7.207,0-9.115,3.934-9.612,6.283c-1.278,6.103-1.413,25.618-1.427,30.003 c-2.606,3.311-15.785,18.903-34.706,21.706c-7.707,1.12-14.904,1.688-21.39,1.688c-5.544,0-9.082-0.428-10.551-0.651l-9.508-10.879 C121.429,254.489,125.177,250.583,124.189,243.535z M136.254,64.149c-0.297,0.128-0.589,0.265-0.876,0.411 c-0.029-0.644-0.096-1.297-0.199-1.952c-1.038-5.975-5-10.312-9.419-10.312c-0.327,0-0.656,0.025-1.017,0.08 c-2.629,0.438-4.691,2.413-5.821,5.213c0.991-6.144,4.472-10.693,8.602-10.693c4.85,0,8.947,6.536,8.947,14.272 C136.471,62.143,136.4,63.113,136.254,64.149z M173.94,68.756c0.444-1.414,0.684-2.944,0.684-4.532 c0-7.014-4.45-12.509-10.131-12.509c-5.552,0-10.069,5.611-10.069,12.509c0,0.47,0.023,0.941,0.067,1.411 c-0.294-0.113-0.581-0.223-0.861-0.329c-0.639-1.935-0.962-3.954-0.962-6.015c0-8.387,5.36-15.211,11.95-15.211 c6.589,0,11.95,6.824,11.95,15.211C176.568,62.78,175.605,66.11,173.94,68.756z M169.081,85.08 c-0.095,0.424-0.297,0.612-2.531,1.774c-1.128,0.587-2.532,1.318-4.289,2.388l-1.174,0.711c-4.718,2.86-15.765,9.559-18.764,9.952 c-2.037,0.274-3.297-0.516-6.13-2.441c-0.639-0.435-1.319-0.897-2.044-1.362c-5.107-3.351-8.392-7.042-8.763-8.485 c1.665-1.287,5.792-4.508,7.905-6.415c4.289-3.988,8.605-6.668,10.741-6.668c0.113,0,0.215,0.008,0.321,0.028 c2.51,0.443,8.701,2.914,13.223,4.718c2.09,0.834,3.895,1.554,5.165,2.01C166.742,82.664,168.828,84.422,169.081,85.08z M205.028,271.45c2.257-10.181,4.857-24.031,4.436-32.196c-0.097-1.855-0.261-3.874-0.42-5.826 c-0.297-3.65-0.738-9.075-0.283-10.684c0.09-0.042,0.19-0.078,0.301-0.109c0.019,4.668,1.033,13.979,8.479,17.226 c2.219,0.968,4.755,1.458,7.537,1.458c7.459,0,15.735-3.659,19.125-7.049c1.996-1.996,3.675-4.438,4.851-6.372 c0.257,0.753,0.415,1.737,0.332,3.005c-0.443,6.885,2.903,16.019,9.271,19.385l0.927,0.487c2.268,1.19,8.292,4.353,8.389,5.853 c-0.001,0.001-0.051,0.177-0.387,0.489c-1.509,1.379-6.82,4.091-11.956,6.714c-9.111,4.652-19.438,9.925-24.076,14.803 c-6.53,6.872-13.916,11.488-18.376,11.488c-0.537,0-1.026-0.068-1.461-0.206C206.873,288.406,202.886,281.417,205.028,271.45z M39.917,245.477c-0.494-2.312-0.884-4.137-0.465-5.905c0.304-1.31,6.771-2.714,9.533-3.313c3.883-0.843,7.899-1.714,10.525-3.308 c3.551-2.151,5.474-6.118,7.17-9.618c1.228-2.531,2.496-5.148,4.005-6.007c0.085-0.05,0.215-0.108,0.463-0.108 c2.827,0,8.759,5.943,12.177,11.262c0.867,1.341,2.473,4.028,4.331,7.139c5.557,9.298,13.166,22.033,17.14,26.301 c3.581,3.837,9.378,11.214,7.952,17.541c-1.044,4.909-6.602,8.901-7.913,9.784c-0.476,0.108-1.065,0.163-1.758,0.163 c-7.606,0-22.662-6.328-30.751-9.728l-1.197-0.503c-4.517-1.894-11.891-3.087-19.022-4.241c-5.674-0.919-13.444-2.176-14.732-3.312 c-1.044-1.171,0.167-4.978,1.235-8.337c0.769-2.414,1.563-4.91,1.998-7.523C41.225,251.596,40.499,248.203,39.917,245.477z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AndroidIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 512 512" fill="currentColor" aria-hidden {...props}>
|
||||
<path d="M120.606,169h270.788v220.663c0,13.109-10.628,23.737-23.721,23.737h-27.123v67.203 c0,17.066-13.612,30.897-30.415,30.897c-16.846,0-30.438-13.831-30.438-30.897v-67.203h-47.371v67.203 c0,17.066-13.639,30.897-30.441,30.897c-16.799,0-30.437-13.831-30.437-30.897v-67.203h-27.099 c-13.096,0-23.744-10.628-23.744-23.737V169z M67.541,167.199c-16.974,0-30.723,13.963-30.723,31.2v121.937 c0,17.217,13.749,31.204,30.723,31.204c16.977,0,30.723-13.987,30.723-31.204V198.399 C98.264,181.162,84.518,167.199,67.541,167.199z M391.395,146.764H120.606c3.342-38.578,28.367-71.776,64.392-90.998 l-25.746-37.804c-3.472-5.098-2.162-12.054,2.946-15.525c5.102-3.471,12.044-2.151,15.533,2.943l28.061,41.232 c15.558-5.38,32.446-8.469,50.208-8.469c17.783,0,34.672,3.089,50.229,8.476L334.29,5.395c3.446-5.108,10.41-6.428,15.512-2.957 c5.108,3.471,6.418,10.427,2.946,15.525l-25.725,37.804C363.047,74.977,388.055,108.175,391.395,146.764z M213.865,94.345 c0-8.273-6.699-14.983-14.969-14.983c-8.291,0-14.99,6.71-14.99,14.983c0,8.269,6.721,14.976,14.99,14.976 S213.865,102.614,213.865,94.345z M329.992,94.345c0-8.273-6.722-14.983-14.99-14.983c-8.291,0-14.97,6.71-14.97,14.983 c0,8.269,6.679,14.976,14.97,14.976C323.271,109.321,329.992,102.614,329.992,94.345z M444.48,167.156 c-16.956,0-30.744,13.984-30.744,31.222v121.98c0,17.238,13.788,31.226,30.744,31.226c16.978,0,30.701-13.987,30.701-31.226 v-121.98C475.182,181.14,461.458,167.156,444.48,167.156z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PostgresIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return <Database {...props} />;
|
||||
}
|
||||
|
||||
/** Font Awesome names used by the Mintlify content -> equivalent icon */
|
||||
const icons: Record<string, IconComponent> = {
|
||||
plug: Plug,
|
||||
link: Link,
|
||||
desktop: Monitor,
|
||||
display: Monitor,
|
||||
sparkles: Sparkles,
|
||||
building: Building2,
|
||||
'user-group': Users,
|
||||
users: Users,
|
||||
user: User,
|
||||
'id-card': IdCard,
|
||||
'chart-bar': ChartColumn,
|
||||
'chart-simple': ChartColumn,
|
||||
'chart-line': ChartLine,
|
||||
bell: Bell,
|
||||
'file-code': FileCode,
|
||||
'circle-nodes': Waypoints,
|
||||
'code-branch': GitBranch,
|
||||
server: Server,
|
||||
'circle-question': CircleHelp,
|
||||
question: CircleHelp,
|
||||
sliders: SlidersHorizontal,
|
||||
box: Box,
|
||||
cube: Box,
|
||||
cubes: Boxes,
|
||||
'boxes-stacked': Boxes,
|
||||
'layer-group': Layers,
|
||||
route: Route,
|
||||
'list-check': ListChecks,
|
||||
globe: Globe,
|
||||
shield: Shield,
|
||||
'network-wired': Network,
|
||||
sitemap: Network,
|
||||
'diagram-project': Workflow,
|
||||
cloud: Cloud,
|
||||
book: BookOpen,
|
||||
download: Download,
|
||||
database: Database,
|
||||
postgres: PostgresIcon,
|
||||
terminal: Terminal,
|
||||
'table-list': TableProperties,
|
||||
robot: Bot,
|
||||
lock: Lock,
|
||||
key: Key,
|
||||
hashtag: Hash,
|
||||
'grid-2': LayoutGrid,
|
||||
envelope: Mail,
|
||||
'credit-card': CreditCard,
|
||||
check: Check,
|
||||
certificate: BadgeCheck,
|
||||
bucket: PaintBucket,
|
||||
brush: Brush,
|
||||
brain: Brain,
|
||||
bolt: Bolt,
|
||||
x: X,
|
||||
github: GitHubIcon,
|
||||
discord: DiscordIcon,
|
||||
slack: SlackIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
youtube: YouTubeIcon,
|
||||
};
|
||||
|
||||
export function Icon({ name, ...props }: { name?: string } & SVGProps<SVGSVGElement>) {
|
||||
if (!name) return null;
|
||||
const Component = icons[name];
|
||||
if (!Component) {
|
||||
if (process.env.NODE_ENV === 'development') console.warn(`[icons] no mapping for "${name}"`);
|
||||
return null;
|
||||
}
|
||||
return <Component {...(props as object)} />;
|
||||
}
|
||||
|
||||
export function hasIcon(name?: string) {
|
||||
return !!name && name in icons;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { remark } from 'remark';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
|
||||
import {
|
||||
Children,
|
||||
type ComponentProps,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
Suspense,
|
||||
use,
|
||||
useDeferredValue,
|
||||
} from 'react';
|
||||
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
|
||||
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
|
||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import type { ElementContent, Root, RootContent } from 'hast';
|
||||
|
||||
export interface Processor {
|
||||
process: (content: string) => Promise<ReactNode>;
|
||||
}
|
||||
|
||||
export function rehypeWrapWords() {
|
||||
return (tree: Root) => {
|
||||
visit(tree, ['text', 'element'], (node, index, parent) => {
|
||||
if (node.type === 'element' && node.tagName === 'pre') return 'skip';
|
||||
if (node.type !== 'text' || !parent || index === undefined) return;
|
||||
|
||||
const words = node.value.split(/(?=\s)/);
|
||||
|
||||
// Create new span nodes for each word and whitespace
|
||||
const newNodes: ElementContent[] = words.flatMap((word) => {
|
||||
if (word.length === 0) return [];
|
||||
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
properties: {
|
||||
class: 'animate-fd-fade-in',
|
||||
},
|
||||
children: [{ type: 'text', value: word }],
|
||||
};
|
||||
});
|
||||
|
||||
Object.assign(node, {
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
properties: {},
|
||||
children: newNodes,
|
||||
} satisfies RootContent);
|
||||
return 'skip';
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createProcessor(): Processor {
|
||||
const processor = remark().use(remarkGfm).use(remarkRehype).use(rehypeWrapWords);
|
||||
|
||||
return {
|
||||
async process(content) {
|
||||
const nodes = processor.parse({ value: content });
|
||||
const hast = await processor.run(nodes);
|
||||
|
||||
return toJsxRuntime(hast, {
|
||||
development: false,
|
||||
jsx,
|
||||
jsxs,
|
||||
Fragment,
|
||||
components: {
|
||||
...defaultMdxComponents,
|
||||
pre: Pre,
|
||||
img: undefined, // use JSX
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Pre(props: ComponentProps<'pre'>) {
|
||||
const code = Children.only(props.children) as ReactElement;
|
||||
const codeProps = code.props as ComponentProps<'code'>;
|
||||
const content = codeProps.children;
|
||||
if (typeof content !== 'string') return null;
|
||||
|
||||
let lang =
|
||||
codeProps.className
|
||||
?.split(' ')
|
||||
.find((v) => v.startsWith('language-'))
|
||||
?.slice('language-'.length) ?? 'text';
|
||||
|
||||
if (lang === 'mdx') lang = 'md';
|
||||
|
||||
return <DynamicCodeBlock lang={lang} code={content.trimEnd()} />;
|
||||
}
|
||||
|
||||
const processor = createProcessor();
|
||||
|
||||
export function Markdown({ text }: { text: string }) {
|
||||
const deferredText = useDeferredValue(text);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<p className="invisible">{text}</p>}>
|
||||
<Renderer text={deferredText} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const cache = new Map<string, Promise<ReactNode>>();
|
||||
|
||||
function Renderer({ text }: { text: string }) {
|
||||
const result = cache.get(text) ?? processor.process(text);
|
||||
cache.set(text, result);
|
||||
|
||||
return use(result);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import { mintlifyComponents } from './mintlify';
|
||||
|
||||
export function getMDXComponents(components?: MDXComponents) {
|
||||
return {
|
||||
...defaultMdxComponents,
|
||||
...mintlifyComponents,
|
||||
...components,
|
||||
} satisfies MDXComponents;
|
||||
}
|
||||
|
||||
export const useMDXComponents = getMDXComponents;
|
||||
|
||||
declare global {
|
||||
type MDXProvidedComponents = ReturnType<typeof getMDXComponents>;
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Drop-in replacements for the Mintlify MDX components used across the docs, so the
|
||||
* content could be ported without rewriting pages. Styling lives in `app/global.css`
|
||||
* under the `pg-*` class names.
|
||||
*/
|
||||
import {
|
||||
Children,
|
||||
isValidElement,
|
||||
type ComponentProps,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import { Tab as FumaTab, Tabs as FumaTabs } from 'fumadocs-ui/components/tabs';
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
CircleCheck,
|
||||
Info as InfoIcon,
|
||||
Lightbulb,
|
||||
OctagonAlert,
|
||||
StickyNote,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { Icon } from './icons';
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Callouts */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
type CalloutType = 'note' | 'info' | 'tip' | 'warning' | 'check' | 'danger';
|
||||
|
||||
const calloutIcons: Record<CalloutType, typeof InfoIcon> = {
|
||||
note: StickyNote,
|
||||
info: InfoIcon,
|
||||
tip: Lightbulb,
|
||||
warning: CircleAlert,
|
||||
check: CircleCheck,
|
||||
danger: OctagonAlert,
|
||||
};
|
||||
|
||||
function Callout({
|
||||
type,
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
type: CalloutType;
|
||||
title?: ReactNode;
|
||||
icon?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const DefaultIcon = calloutIcons[type];
|
||||
return (
|
||||
<div className="pg-callout not-prose" data-type={type} role="note">
|
||||
<span className="pg-callout-icon">
|
||||
{icon ? <Icon name={icon} /> : <DefaultIcon />}
|
||||
</span>
|
||||
<div className="pg-callout-body prose">
|
||||
{title && <p className="font-medium">{title}</p>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CalloutProps = { title?: ReactNode; icon?: string; children?: ReactNode };
|
||||
export const Note = (p: CalloutProps) => <Callout type="note" {...p} />;
|
||||
export const Info = (p: CalloutProps) => <Callout type="info" {...p} />;
|
||||
export const Tip = (p: CalloutProps) => <Callout type="tip" {...p} />;
|
||||
export const Warning = (p: CalloutProps) => <Callout type="warning" {...p} />;
|
||||
export const Check = (p: CalloutProps) => <Callout type="check" {...p} />;
|
||||
export const Danger = (p: CalloutProps) => <Callout type="danger" {...p} />;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Cards */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function Card({
|
||||
title,
|
||||
icon,
|
||||
href,
|
||||
arrow,
|
||||
cta,
|
||||
horizontal,
|
||||
img,
|
||||
children,
|
||||
}: {
|
||||
title?: ReactNode;
|
||||
icon?: string;
|
||||
href?: string;
|
||||
arrow?: boolean | string;
|
||||
cta?: string;
|
||||
horizontal?: boolean;
|
||||
img?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const external = href ? /^https?:\/\//.test(href) : false;
|
||||
const showArrow = arrow === true || arrow === 'true' || (arrow === undefined && external);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{img && <img src={img} alt="" className="pg-card-img" />}
|
||||
<div className={cn('pg-card-inner', horizontal && 'pg-card-horizontal')}>
|
||||
{icon && (
|
||||
<span className="pg-card-icon">
|
||||
<Icon name={icon} />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && (
|
||||
<p className="pg-card-title">
|
||||
{title}
|
||||
{showArrow && <ArrowRight className="pg-card-arrow" />}
|
||||
</p>
|
||||
)}
|
||||
{children && <div className="pg-card-content prose">{children}</div>}
|
||||
{cta && (
|
||||
<p className="pg-card-cta">
|
||||
{cta}
|
||||
<ChevronRight className="size-3.5" />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!href) return <div className="pg-card not-prose">{content}</div>;
|
||||
// A full-card overlay link instead of wrapping the card in <a>: card bodies can contain
|
||||
// their own links, and nested <a> elements are invalid HTML (hydration errors).
|
||||
return (
|
||||
<div className="pg-card pg-card-link not-prose" data-card="">
|
||||
<Link
|
||||
href={href}
|
||||
external={external}
|
||||
className="pg-card-overlay"
|
||||
aria-label={typeof title === 'string' ? title : cta}
|
||||
/>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardGroup({ cols = 2, children }: { cols?: number; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="pg-card-group not-prose" style={{ '--pg-cols': cols } as React.CSSProperties}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Columns = CardGroup;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Steps */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function Steps({ children }: { children?: ReactNode }) {
|
||||
return <div className="pg-steps">{children}</div>;
|
||||
}
|
||||
|
||||
export function Step({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
title?: ReactNode;
|
||||
icon?: string;
|
||||
stepNumber?: number;
|
||||
titleSize?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="pg-step">
|
||||
<span className="pg-step-marker" aria-hidden>
|
||||
{icon ? <Icon name={icon} className="size-3.5" /> : null}
|
||||
</span>
|
||||
{title && <p className="pg-step-title">{title}</p>}
|
||||
<div className="pg-step-content">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Tabs */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
type TabElement = ReactElement<{ title?: string; children?: ReactNode }>;
|
||||
|
||||
export function Tabs({ children }: { children?: ReactNode }) {
|
||||
const tabs = Children.toArray(children).filter(
|
||||
(child): child is TabElement => isValidElement(child),
|
||||
);
|
||||
const items = tabs.map((tab, i) => tab.props.title ?? `Tab ${i + 1}`);
|
||||
|
||||
return (
|
||||
<FumaTabs items={items}>
|
||||
{tabs.map((tab, i) => (
|
||||
<FumaTab key={items[i]} value={items[i]}>
|
||||
{tab.props.children}
|
||||
</FumaTab>
|
||||
))}
|
||||
</FumaTabs>
|
||||
);
|
||||
}
|
||||
|
||||
/** Only rendered through <Tabs>, which reads its props directly. */
|
||||
export function Tab({ children }: { title?: string; children?: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Accordions & Expandables */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function slugify(value: unknown) {
|
||||
return typeof value === 'string'
|
||||
? value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function Accordion({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
defaultOpen,
|
||||
children,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
icon?: string;
|
||||
defaultOpen?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<details className="pg-accordion" open={defaultOpen} id={slugify(title)}>
|
||||
<summary>
|
||||
<ChevronRight className="pg-accordion-chevron" />
|
||||
{icon && <Icon name={icon} className="size-4 shrink-0 text-fd-muted-foreground" />}
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">{title}</span>
|
||||
{description && (
|
||||
<span className="block text-sm text-fd-muted-foreground">{description}</span>
|
||||
)}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="pg-accordion-content prose">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccordionGroup({ children }: { children?: ReactNode }) {
|
||||
return <div className="pg-accordion-group">{children}</div>;
|
||||
}
|
||||
|
||||
export function Expandable({
|
||||
title = 'properties',
|
||||
defaultOpen,
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
defaultOpen?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<details className="pg-expandable" open={defaultOpen}>
|
||||
<summary>
|
||||
<ChevronRight className="pg-accordion-chevron" />
|
||||
<span className="pg-expandable-closed">Show {title}</span>
|
||||
<span className="pg-expandable-open">Hide {title}</span>
|
||||
</summary>
|
||||
<div className="pg-expandable-content">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* API fields */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function ResponseField({
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
default: defaultValue,
|
||||
deprecated,
|
||||
post,
|
||||
pre,
|
||||
children,
|
||||
}: {
|
||||
name: string;
|
||||
type?: string;
|
||||
required?: boolean;
|
||||
default?: unknown;
|
||||
deprecated?: boolean;
|
||||
post?: string[];
|
||||
pre?: string[];
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const id = slugify(name);
|
||||
return (
|
||||
<div className="pg-field" id={id ? `field-${id}` : undefined}>
|
||||
<div className="pg-field-header">
|
||||
{pre?.map((p) => (
|
||||
<span key={p} className="pg-field-pill">
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
<code className="pg-field-name">{name}</code>
|
||||
{type && <span className="pg-field-type">{type}</span>}
|
||||
{defaultValue !== undefined && (
|
||||
<span className="pg-field-pill">
|
||||
default: <code>{String(defaultValue)}</code>
|
||||
</span>
|
||||
)}
|
||||
{post?.map((p) => (
|
||||
<span key={p} className="pg-field-pill">
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
{required && <span className="pg-field-required">required</span>}
|
||||
{deprecated && <span className="pg-field-deprecated">deprecated</span>}
|
||||
</div>
|
||||
{children && <div className="pg-field-body prose">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ParamField = ResponseField;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Media */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function Frame({
|
||||
caption,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
caption?: ReactNode;
|
||||
hint?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<figure className="pg-frame not-prose">
|
||||
<div className="pg-frame-inner">
|
||||
{hint && <p className="pg-frame-hint">{hint}</p>}
|
||||
{children}
|
||||
{caption && <figcaption>{caption}</figcaption>}
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
export function Iframe(props: ComponentProps<'iframe'>) {
|
||||
return (
|
||||
<Frame>
|
||||
<iframe {...props} />
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
export function Video(props: ComponentProps<'video'>) {
|
||||
return (
|
||||
<Frame>
|
||||
<video {...props} />
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
/** plain <img>: content images are referenced by absolute `/images/...` paths */
|
||||
export function Img({
|
||||
centered: _centered,
|
||||
noZoom: _noZoom,
|
||||
...props
|
||||
}: ComponentProps<'img'> & { centered?: boolean; noZoom?: boolean }) {
|
||||
return <img loading="lazy" {...props} alt={props.alt ?? ''} />;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Misc */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function Update({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="pg-update" id={slugify(label)}>
|
||||
<div className="pg-update-meta">
|
||||
<span className="pg-field-pill">{label}</span>
|
||||
{description && <p className="text-sm text-fd-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
<div className="prose">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MintLink(props: ComponentProps<'a'>) {
|
||||
const external = props.href ? /^https?:\/\//.test(props.href) : false;
|
||||
return <Link {...props} href={props.href ?? '#'} external={external} />;
|
||||
}
|
||||
|
||||
export const mintlifyComponents = {
|
||||
Note,
|
||||
Info,
|
||||
Tip,
|
||||
Warning,
|
||||
Check,
|
||||
Danger,
|
||||
Card,
|
||||
CardGroup,
|
||||
Columns,
|
||||
Steps,
|
||||
Step,
|
||||
Tabs,
|
||||
Tab,
|
||||
Accordion,
|
||||
AccordionGroup,
|
||||
Expandable,
|
||||
ResponseField,
|
||||
ParamField,
|
||||
Frame,
|
||||
iframe: Iframe,
|
||||
video: Video,
|
||||
Update,
|
||||
Link: MintLink,
|
||||
img: Img,
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import navigation from '@/lib/navigation.json';
|
||||
import { Icon } from './icons';
|
||||
|
||||
/**
|
||||
* Sidebar header with the global anchors (Discord, Slack) from `lib/navigation.json`,
|
||||
* styled like Mintlify's anchors. Rendered through the sidebar `banner` slot as a
|
||||
* component so it owns the header padding; the bottom spacing matches the gap between
|
||||
* sidebar groups.
|
||||
*/
|
||||
export function SidebarAnchors({ children, className, ...props }: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div {...props} className={cn('flex flex-col px-4 pt-4', className)}>
|
||||
{children}
|
||||
<ul className="flex flex-col gap-1 max-lg:mt-4">
|
||||
{navigation.anchors.map((anchor) => (
|
||||
<li key={anchor.href}>
|
||||
<a
|
||||
href={anchor.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="pg-anchor"
|
||||
>
|
||||
<span className="pg-anchor-icon">
|
||||
<Icon name={anchor.icon ?? undefined} />
|
||||
</span>
|
||||
{anchor.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
AndroidIcon,
|
||||
AppleIcon,
|
||||
GitHubIcon,
|
||||
LinuxIcon,
|
||||
LinkedInIcon,
|
||||
WindowsIcon,
|
||||
YouTubeIcon,
|
||||
} from './icons';
|
||||
import { Logo } from '@/lib/layout.shared';
|
||||
|
||||
const downloads = [
|
||||
{ label: 'macOS', href: 'https://pangolin.net/downloads/mac', icon: AppleIcon },
|
||||
{ label: 'iOS', href: 'https://pangolin.net/downloads/ios', icon: AppleIcon },
|
||||
{ label: 'Windows', href: 'https://pangolin.net/downloads/windows', icon: WindowsIcon },
|
||||
{ label: 'Android', href: 'https://pangolin.net/downloads/android', icon: AndroidIcon },
|
||||
{ label: 'Linux', href: 'https://pangolin.net/downloads/linux', icon: LinuxIcon },
|
||||
];
|
||||
|
||||
const socials = [
|
||||
{ label: 'GitHub', href: 'https://github.com/fosrl/pangolin', icon: GitHubIcon },
|
||||
{ label: 'LinkedIn', href: 'https://linkedin.com/company/pangolin-net', icon: LinkedInIcon },
|
||||
{ label: 'YouTube', href: 'https://youtube.com/@pangolin-net', icon: YouTubeIcon },
|
||||
];
|
||||
|
||||
export function SiteFooter() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="pg-footer">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Logo />
|
||||
<span className="h-6 w-px bg-fd-border" aria-hidden />
|
||||
<div className="flex gap-3">
|
||||
{socials.map(({ label, href, icon: Icon }) => (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
aria-label={label}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-fd-muted-foreground transition-colors hover:text-fd-foreground"
|
||||
>
|
||||
<Icon className="size-4.5" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{downloads.map(({ label, href, icon: Icon }) => (
|
||||
<a key={label} href={href} className="pg-download">
|
||||
<Icon className="size-4" />
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2 text-sm text-fd-muted-foreground">
|
||||
<a
|
||||
href="https://status.pangolin.net"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-2 text-fd-muted-foreground no-underline transition-colors hover:text-fd-foreground"
|
||||
>
|
||||
<span className="size-2 shrink-0 rounded-full bg-[#2d8a4e]" aria-hidden />
|
||||
All systems operational
|
||||
</a>
|
||||
<p>© {year} Fossorial Inc.</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
const primary = 'bg-fd-primary text-fd-primary-foreground hover:bg-fd-primary/80';
|
||||
|
||||
const variants = {
|
||||
default: primary,
|
||||
primary,
|
||||
outline: 'border hover:bg-fd-accent hover:text-fd-accent-foreground',
|
||||
ghost: 'hover:bg-fd-accent hover:text-fd-accent-foreground',
|
||||
secondary:
|
||||
'border bg-fd-secondary text-fd-secondary-foreground hover:bg-fd-accent hover:text-fd-accent-foreground',
|
||||
} as const;
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md p-2 text-sm font-medium transition-colors duration-100 disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring',
|
||||
{
|
||||
variants: {
|
||||
variant: variants,
|
||||
// `color` and `primary` predate the Shadcn UI compatible names
|
||||
color: variants,
|
||||
size: {
|
||||
sm: 'gap-1 px-2 py-1.5 text-xs',
|
||||
icon: 'p-1.5 [&_svg]:size-5',
|
||||
'icon-sm': 'p-1.5 [&_svg]:size-4.5',
|
||||
'icon-xs': 'p-1 [&_svg]:size-4',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export type ButtonProps = VariantProps<typeof buttonVariants>;
|
||||
Reference in New Issue
Block a user