mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 11:24:57 -05:00
docs: migrate search to Typesense and redesign docs (#8487)
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
# GitHub API - community stats
|
||||
GITHUB_TOKEN=
|
||||
|
||||
# Inkeep API - docs search & AI chat
|
||||
# Inkeep API - AI chat
|
||||
INKEEP_API_KEY=
|
||||
|
||||
# Typesense - Docs search
|
||||
NEXT_PUBLIC_TYPESENSE_SERVER_URL=
|
||||
NEXT_PUBLIC_TYPESENSE_SEARCH_API_KEY=
|
||||
TYPESENSE_ADMIN_API_KEY=
|
||||
|
||||
# Next.js - public URL
|
||||
NEXT_PUBLIC_URL=http://localhost:3000
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
url: string;
|
||||
type: "page" | "heading" | "text";
|
||||
content: string;
|
||||
pageName?: string;
|
||||
}
|
||||
|
||||
interface InkeepDocument {
|
||||
type: string;
|
||||
record_type: string;
|
||||
url: string;
|
||||
title: string;
|
||||
source?: {
|
||||
type: string;
|
||||
content: { type: string; text?: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
function toSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function toRelativePath(fullUrl: string): string {
|
||||
try {
|
||||
const parsed = new URL(fullUrl);
|
||||
return parsed.pathname;
|
||||
} catch {
|
||||
return fullUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function extractHeadings(text: string): { level: number; title: string }[] {
|
||||
const headings: { level: number; title: string }[] = [];
|
||||
const regex = /^(#{1,6})\s+(.+)$/gm;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
headings.push({
|
||||
level: match[1].length,
|
||||
title: match[2].trim(),
|
||||
});
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
|
||||
function extractMatchingSnippet(text: string, query: string): string | null {
|
||||
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
|
||||
// Find the first paragraph-like block that contains a query term
|
||||
const blocks = text.split(/\n{2,}/);
|
||||
for (const block of blocks) {
|
||||
const blockLower = block.toLowerCase();
|
||||
// Skip headings and very short blocks
|
||||
if (block.startsWith("#") || block.trim().length < 20) continue;
|
||||
if (terms.some((t) => blockLower.includes(t))) {
|
||||
// Return a trimmed snippet
|
||||
const clean = block.replace(/[#*_`>\[\]]/g, "").trim();
|
||||
if (clean.length > 120) return `${clean.slice(0, 120)}...`;
|
||||
return clean;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const query = url.searchParams.get("query");
|
||||
if (!query) return Response.json([]);
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.inkeep.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.INKEEP_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "inkeep-rag",
|
||||
messages: [{ role: "user", content: query }],
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Inkeep RAG API error:", response.status);
|
||||
return Response.json([]);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
if (!content) return Response.json([]);
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
const documents: InkeepDocument[] = parsed.content ?? [];
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
const seenUrls = new Set<string>();
|
||||
|
||||
for (const doc of documents) {
|
||||
const docUrl = toRelativePath(doc.url);
|
||||
|
||||
// Add page-level result (dedupe by base URL)
|
||||
const baseUrl = docUrl.split("#")[0];
|
||||
if (!seenUrls.has(baseUrl)) {
|
||||
seenUrls.add(baseUrl);
|
||||
results.push({
|
||||
id: baseUrl,
|
||||
url: baseUrl,
|
||||
type: "page",
|
||||
content: doc.title,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract full text from source content blocks
|
||||
const fullText = (doc.source?.content ?? [])
|
||||
.filter((c) => c.type === "text" && c.text)
|
||||
.map((c) => c.text!)
|
||||
.join("\n\n");
|
||||
|
||||
if (!fullText) continue;
|
||||
|
||||
// Extract headings and add as section results
|
||||
const headings = extractHeadings(fullText);
|
||||
const queryTerms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
|
||||
for (const heading of headings) {
|
||||
const headingLower = heading.title.toLowerCase();
|
||||
const matches = queryTerms.some((t) => headingLower.includes(t));
|
||||
if (!matches) continue;
|
||||
|
||||
const slug = toSlug(heading.title);
|
||||
const headingUrl = `${baseUrl}#${slug}`;
|
||||
if (seenUrls.has(headingUrl)) continue;
|
||||
seenUrls.add(headingUrl);
|
||||
|
||||
results.push({
|
||||
id: headingUrl,
|
||||
url: headingUrl,
|
||||
type: "heading",
|
||||
content: heading.title,
|
||||
pageName: doc.title,
|
||||
});
|
||||
}
|
||||
|
||||
// Add a text snippet match if we find relevant content
|
||||
const snippet = extractMatchingSnippet(fullText, query);
|
||||
if (snippet) {
|
||||
const snippetId = `${baseUrl}:snippet`;
|
||||
if (!seenUrls.has(snippetId)) {
|
||||
seenUrls.add(snippetId);
|
||||
|
||||
// Link to the closest heading above the snippet, or the page
|
||||
let bestAnchor = baseUrl;
|
||||
for (const heading of headings) {
|
||||
const slug = toSlug(heading.title);
|
||||
bestAnchor = `${baseUrl}#${slug}`;
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: snippetId,
|
||||
url: bestAnchor,
|
||||
type: "text",
|
||||
content: snippet,
|
||||
pageName: doc.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json(results.slice(0, 20));
|
||||
} catch (error) {
|
||||
console.error("Search error:", error);
|
||||
return Response.json([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { exportSearchIndexes } from "@/lib/export-search-indexes";
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET() {
|
||||
return Response.json(await exportSearchIndexes());
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||
import { RootProvider } from "fumadocs-ui/provider/next";
|
||||
import type { ReactNode } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { AIChat, AIChatPanel, AIChatTrigger } from "@/components/ai-chat";
|
||||
import { DocsSidebar } from "@/components/docs/docs-sidebar";
|
||||
import { FloatingToolbar } from "@/components/floating-toolbar";
|
||||
import { source } from "@/lib/source";
|
||||
import type { PageEntry } from "./provider";
|
||||
import { DocsProvider } from "./provider";
|
||||
|
||||
const allPages: PageEntry[] = source.getPages().map((page) => ({
|
||||
name: page.data.title,
|
||||
url: page.url,
|
||||
}));
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<RootProvider
|
||||
search={{
|
||||
enabled: false,
|
||||
}}
|
||||
>
|
||||
<DocsProvider pages={allPages}>
|
||||
<Suspense>
|
||||
<DocsSidebar />
|
||||
</Suspense>
|
||||
@@ -27,8 +29,17 @@ export default function Layout({ children }: { children: ReactNode }) {
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<AIChat>
|
||||
<AIChatPanel />
|
||||
<AIChatTrigger>
|
||||
<span className="text-sm text-muted-foreground">Ask AI</span>
|
||||
<span className="h-5 w-px bg-foreground/10" />
|
||||
<kbd className="inline-flex items-center gap-0.5 text-[10px] font-mono text-muted-foreground">
|
||||
<span className="text-[11px]">⌘</span>I
|
||||
</kbd>
|
||||
</AIChatTrigger>
|
||||
</AIChat>
|
||||
</DocsLayout>
|
||||
<FloatingToolbar />
|
||||
</RootProvider>
|
||||
</DocsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { RootProvider } from "fumadocs-ui/provider/next";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, use } from "react";
|
||||
|
||||
const SearchDialog = dynamic(() => import("@/components/search-dialog"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export interface PageEntry {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const PagesContext = createContext<PageEntry[]>([]);
|
||||
|
||||
export function usePages() {
|
||||
return use(PagesContext);
|
||||
}
|
||||
|
||||
export function DocsProvider({
|
||||
pages,
|
||||
children,
|
||||
}: {
|
||||
pages: PageEntry[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PagesContext value={pages}>
|
||||
<RootProvider
|
||||
search={{
|
||||
SearchDialog,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RootProvider>
|
||||
</PagesContext>
|
||||
);
|
||||
}
|
||||
@@ -674,6 +674,14 @@ html:not([data-anchor-scrolling]) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide scrollbar in AI Chat panel */
|
||||
.ai-chat-messages {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent scroll-chaining and hide scrollbars in TOC areas */
|
||||
.docs-layout #nd-toc > div {
|
||||
overscroll-behavior-y: contain;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Analytics } from "@vercel/analytics/next";
|
||||
import type { Metadata } from "next";
|
||||
import Script from "next/script";
|
||||
import type { ReactNode } from "react";
|
||||
import { CommandMenuProvider } from "@/components/command-menu";
|
||||
import { StaggeredNavFiles } from "@/components/landing/staggered-nav-files";
|
||||
import { Providers } from "@/components/providers";
|
||||
|
||||
@@ -97,12 +96,10 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<Providers>
|
||||
<CommandMenuProvider>
|
||||
<div className="relative min-h-dvh">
|
||||
<StaggeredNavFiles />
|
||||
{children}
|
||||
</div>
|
||||
</CommandMenuProvider>
|
||||
<div className="relative min-h-dvh">
|
||||
<StaggeredNavFiles />
|
||||
{children}
|
||||
</div>
|
||||
</Providers>
|
||||
<Analytics />
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
"use client";
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { Loader2, MessageCircleIcon, RefreshCw, Send, X } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode, SyntheticEvent } from "react";
|
||||
import {
|
||||
createContext,
|
||||
use,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Markdown } from "./markdown";
|
||||
import { MessageFeedback } from "./message-feedback";
|
||||
|
||||
// ─── Context ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const AIChatContext = createContext<{
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
chat: ReturnType<typeof useChat>;
|
||||
} | null>(null);
|
||||
|
||||
export function useAIChat() {
|
||||
const ctx = use(AIChatContext);
|
||||
if (!ctx) throw new Error("Missing <AIChat />");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function useChatContext() {
|
||||
return useAIChat().chat;
|
||||
}
|
||||
|
||||
// ─── Root ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_PANEL_WIDTH = 400;
|
||||
const MIN_PANEL_WIDTH = 320;
|
||||
const MAX_PANEL_WIDTH = 640;
|
||||
|
||||
const chatTransport = new DefaultChatTransport({
|
||||
api: "/api/docs/chat",
|
||||
});
|
||||
|
||||
export function AIChat({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const chat = useChat({
|
||||
id: "ai-chat",
|
||||
transport: chatTransport,
|
||||
});
|
||||
|
||||
// Support ?askai= URL param
|
||||
const handleAskAiParam = useEffectEvent(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const aiQuery = params.get("askai");
|
||||
if (aiQuery) {
|
||||
setOpen(true);
|
||||
void chat.sendMessage({ text: aiQuery });
|
||||
const newParams = new URLSearchParams(window.location.search);
|
||||
newParams.delete("askai");
|
||||
const newUrl = newParams.toString()
|
||||
? `${window.location.pathname}?${newParams.toString()}`
|
||||
: window.location.pathname;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
handleAskAiParam();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AIChatContext
|
||||
value={useMemo(() => ({ chat, open, setOpen }), [chat, open])}
|
||||
>
|
||||
{children}
|
||||
</AIChatContext>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Trigger ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AIChatTrigger({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<"button">) {
|
||||
const { open, setOpen } = useAIChat();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-state={open ? "open" : "closed"}
|
||||
className={cn(
|
||||
"fixed bottom-5 end-5 z-20 flex items-center gap-2.5 px-3.5 py-1.5 border bg-secondary/70 backdrop-blur-md shadow-lg transition-[translate,opacity]",
|
||||
open && "translate-y-10 opacity-0",
|
||||
className,
|
||||
)}
|
||||
onClick={() => setOpen(!open)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Panel ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AIChatPanel() {
|
||||
const { open, setOpen } = useAIChat();
|
||||
const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const startX = useRef(0);
|
||||
const startWidth = useRef(panelWidth);
|
||||
useAIChatHotKey();
|
||||
|
||||
// Mobile: lock body scroll when panel is open
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const mq = window.matchMedia("(max-width: 1023px)");
|
||||
if (!mq.matches) return;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const delta = startX.current - e.clientX;
|
||||
setPanelWidth(
|
||||
Math.min(
|
||||
MAX_PANEL_WIDTH,
|
||||
Math.max(MIN_PANEL_WIDTH, startWidth.current + delta),
|
||||
),
|
||||
);
|
||||
};
|
||||
const onMouseUp = () => setDragging(false);
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [dragging]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
<div
|
||||
className="fixed inset-0 z-[200] backdrop-blur-xs bg-black/50 lg:hidden"
|
||||
onClick={() => setOpen(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden overscroll-contain z-200 bg-background text-foreground",
|
||||
"[--ai-chat-width:400px]",
|
||||
// Mobile
|
||||
"max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:h-[80dvh] max-lg:border-t max-lg:shadow-[0_-8px_50px_0px_rgba(0,0,0,0.12)] max-lg:dark:shadow-[0_-8px_50px_0px_rgba(255,255,255,0.07)]",
|
||||
// Desktop
|
||||
"lg:fixed lg:top-0 lg:inset-e-0 lg:h-dvh lg:border-s lg:shadow-[-8px_0_24px_-4px_rgba(0,0,0,0.1)] lg:dark:shadow-[-8px_0_30px_-2px_rgba(0,0,0,0.7)]",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--ai-chat-width": `${panelWidth}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Resize handle (desktop) */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 w-1 cursor-col-resize hover:bg-foreground/10 active:bg-foreground/15 transition-colors hidden lg:block z-10"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
startX.current = e.clientX;
|
||||
startWidth.current = panelWidth;
|
||||
setDragging(true);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col size-full min-h-0 overflow-hidden p-2 pb-3 max-lg:h-full lg:p-3 lg:w-(--ai-chat-width)">
|
||||
<PanelHeader />
|
||||
<PanelMessages className="flex-1" />
|
||||
<PanelInput />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full-screen overlay during drag */}
|
||||
{dragging && <div className="fixed inset-0 z-[201] cursor-col-resize" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Panel Header ────────────────────────────────────────────────────────────
|
||||
|
||||
function PanelHeader() {
|
||||
const { setOpen } = useAIChat();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 border rounded-lg bg-foreground/2 p-2 mb-2">
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium mb-1">AI Chat</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Powered by{" "}
|
||||
<a
|
||||
href="https://inkeep.com"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="underline hover:text-foreground transition-colors"
|
||||
>
|
||||
Inkeep AI
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className="p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Panel Messages ──────────────────────────────────────────────────────────
|
||||
|
||||
function PanelMessages({ className, ...props }: ComponentProps<"div">) {
|
||||
const { messages } = useChatContext();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const filtered = messages.filter((msg) => msg.role !== "system");
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const container = containerRef.current;
|
||||
|
||||
const scrollToBottom = () => {
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: "instant",
|
||||
});
|
||||
};
|
||||
|
||||
// Observe size changes on all current and future children
|
||||
const resizeObserver = new ResizeObserver(scrollToBottom);
|
||||
const observeChildren = () => {
|
||||
resizeObserver.disconnect();
|
||||
for (const child of container.children) {
|
||||
resizeObserver.observe(child);
|
||||
}
|
||||
};
|
||||
|
||||
observeChildren();
|
||||
|
||||
// Re-attach when children are added/removed
|
||||
const mutationObserver = new MutationObserver(observeChildren);
|
||||
mutationObserver.observe(container, { childList: true });
|
||||
|
||||
// Prevent scroll chaining to body on desktop
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const atTop = scrollTop <= 0 && e.deltaY < 0;
|
||||
const atBottom = scrollTop + clientHeight >= scrollHeight && e.deltaY > 0;
|
||||
if (atTop || atBottom) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
container.addEventListener("wheel", onWheel, { passive: false });
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
container.removeEventListener("wheel", onWheel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"ai-chat-messages overflow-y-auto overscroll-contain min-w-0 min-h-0 flex flex-col py-4",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 1rem), transparent 100%)",
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="size-full flex flex-col items-center justify-center text-center gap-2 text-sm text-muted-foreground/80 py-6">
|
||||
<MessageCircleIcon
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
className="size-5"
|
||||
/>
|
||||
<p>Start a new chat below.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col px-1 gap-4">
|
||||
{filtered.map((item, index) => (
|
||||
<Message
|
||||
key={item.id}
|
||||
message={item}
|
||||
messages={filtered}
|
||||
messageIndex={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Panel Input ─────────────────────────────────────────────────────────────
|
||||
|
||||
function PanelInput() {
|
||||
const { status, sendMessage, stop, setMessages, messages, regenerate } =
|
||||
useChatContext();
|
||||
const [input, setInput] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isLoading = status === "streaming" || status === "submitted";
|
||||
const inputDragRef = useRef<{ startY: number; startHeight: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [inputMinHeight, setInputMinHeight] = useState(38);
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
const maxH = window.innerHeight * 0.35;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(maxH, Math.max(el.scrollHeight, inputMinHeight))}px`;
|
||||
}, [inputMinHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
adjustHeight();
|
||||
}, [adjustHeight]);
|
||||
|
||||
const onSubmit = (e?: SyntheticEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!input.trim() || isLoading) return;
|
||||
void sendMessage({ text: input });
|
||||
setInput("");
|
||||
requestAnimationFrame(adjustHeight);
|
||||
};
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
inputDragRef.current = { startY: e.clientY, startHeight: inputMinHeight };
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!inputDragRef.current) return;
|
||||
const delta = inputDragRef.current.startY - ev.clientY;
|
||||
setInputMinHeight(
|
||||
Math.max(38, Math.min(400, inputDragRef.current.startHeight + delta)),
|
||||
);
|
||||
};
|
||||
const onUp = () => {
|
||||
inputDragRef.current = null;
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.cursor = "row-resize";
|
||||
},
|
||||
[inputMinHeight],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Drag handle to resize input */}
|
||||
<div
|
||||
onMouseDown={handleDragStart}
|
||||
className="absolute -top-1.5 left-1/2 -translate-x-1/2 z-10 hidden lg:flex items-center justify-center w-8 h-3 cursor-row-resize group/drag"
|
||||
>
|
||||
<div className="w-8 h-[2px] rounded-full bg-foreground/20 dark:bg-white/20 group-hover/drag:bg-foreground/40 transition-colors" />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border transition-all duration-200",
|
||||
"border-border/60 dark:border-white/8",
|
||||
"bg-card/50 dark:bg-white/2",
|
||||
"focus-within:border-foreground/15 dark:focus-within:border-white/12",
|
||||
"focus-within:bg-background dark:focus-within:bg-white/3",
|
||||
"focus-within:shadow-[0_0_0_1px_rgba(0,0,0,0.04)] dark:focus-within:shadow-[0_0_0_1px_rgba(255,255,255,0.03)]",
|
||||
)}
|
||||
>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="flex items-end">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id="nd-ai-input"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
adjustHeight();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!e.shiftKey && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
placeholder={isLoading ? "AI is answering..." : "Ask a question"}
|
||||
autoFocus
|
||||
rows={1}
|
||||
style={{
|
||||
height: Math.max(inputMinHeight, 38),
|
||||
}}
|
||||
className="flex-1 resize-none text-[13px] bg-transparent pl-3.5 pr-1.5 py-2.5 placeholder:text-muted-foreground focus:outline-none overflow-y-auto"
|
||||
/>
|
||||
<div className="shrink-0 pb-1.5 pr-1.5">
|
||||
{isLoading ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={stop}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-border text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-all duration-150 cursor-pointer"
|
||||
title="Stop generating"
|
||||
>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={input.trim().length === 0}
|
||||
className={cn(
|
||||
"inline-flex h-7 w-7 items-center justify-center rounded-full transition-all duration-150",
|
||||
input.trim()
|
||||
? "bg-foreground text-background hover:bg-foreground/90 cursor-pointer"
|
||||
: "bg-muted/50 dark:bg-white/5 text-muted-foreground cursor-default",
|
||||
)}
|
||||
title="Send (Enter)"
|
||||
>
|
||||
<Send className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 p-1 empty:hidden">
|
||||
{!isLoading &&
|
||||
messages.length > 0 &&
|
||||
messages.at(-1)?.role === "assistant" && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 text-xs border rounded-full text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => regenerate()}
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
{messages.length > 0 && !isLoading && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 text-xs border rounded-full text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setMessages([])}
|
||||
>
|
||||
Clear Chat
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Message ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const roleName: Record<string, string> = {
|
||||
user: "you",
|
||||
assistant: "BA bot",
|
||||
};
|
||||
|
||||
function Message({
|
||||
message,
|
||||
messages,
|
||||
messageIndex,
|
||||
}: {
|
||||
message: UIMessage;
|
||||
messages: UIMessage[];
|
||||
messageIndex: number;
|
||||
}) {
|
||||
const { status } = useChatContext();
|
||||
const isLastAssistant =
|
||||
message.role === "assistant" && messageIndex === messages.length - 1;
|
||||
const isStreaming =
|
||||
(status === "streaming" || status === "submitted") && isLastAssistant;
|
||||
|
||||
let markdown = "";
|
||||
for (const part of message.parts ?? []) {
|
||||
if (part.type === "text") {
|
||||
markdown += part.text;
|
||||
}
|
||||
}
|
||||
|
||||
if (message.role === "assistant") {
|
||||
// Remove Inkeep citation markers like (1), (2) etc.
|
||||
markdown = markdown.replace(/\(\d+\)/g, "");
|
||||
// Remove stray backslashes used as line breaks by Inkeep
|
||||
markdown = markdown.replace(/^\s*\\\s*$/gm, "");
|
||||
}
|
||||
|
||||
// Fix incomplete code blocks
|
||||
const codeBlockCount = (markdown.match(/```/g) || []).length;
|
||||
if (codeBlockCount % 2 !== 0) {
|
||||
markdown += "\n```";
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"mb-1 text-xs font-medium text-muted-foreground",
|
||||
message.role === "assistant" && "text-foreground",
|
||||
)}
|
||||
>
|
||||
{roleName[message.role] ?? "unknown"}
|
||||
</p>
|
||||
<div className="text-sm prose prose-sm max-w-none [&_pre]:!text-[11px]">
|
||||
<Markdown text={markdown} />
|
||||
</div>
|
||||
{message.role === "assistant" && message.id && !isStreaming && (
|
||||
<MessageFeedback
|
||||
messageId={message.id}
|
||||
userMessageId={
|
||||
messageIndex > 0 ? messages[messageIndex - 1]?.id : undefined
|
||||
}
|
||||
content={markdown}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Hot Key ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function useAIChatHotKey() {
|
||||
const { open, setOpen } = useAIChat();
|
||||
|
||||
const onKeyPress = useEffectEvent((e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) {
|
||||
setOpen(false);
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key === "i" && (e.metaKey || e.ctrlKey)) {
|
||||
setOpen(!open);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", onKeyPress);
|
||||
return () => window.removeEventListener("keydown", onKeyPress);
|
||||
}, []);
|
||||
}
|
||||
@@ -5,11 +5,11 @@ import type {
|
||||
Item as PageTreeItem,
|
||||
Separator as PageTreeSeparator,
|
||||
} from "fumadocs-core/page-tree";
|
||||
import { useSearchContext } from "fumadocs-ui/contexts/search";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { useCommandMenu } from "@/components/command-menu";
|
||||
|
||||
// ─── Item ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -111,7 +111,7 @@ export const CustomSeparator: FC<{ item: PageTreeSeparator }> = ({ item }) => {
|
||||
// ─── Search Button (sidebar banner) ─────────────────────────────────────────
|
||||
|
||||
export const SearchButton: FC = () => {
|
||||
const { setOpen } = useCommandMenu();
|
||||
const { setOpenSearch: setOpen } = useSearchContext();
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, MotionConfig, motion } from "framer-motion";
|
||||
import { ChevronDownIcon, GitBranch, Search, Sparkles } from "lucide-react";
|
||||
import { useSearchContext } from "fumadocs-ui/contexts/search";
|
||||
import { ChevronDownIcon, GitBranch, Search } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCommandMenu } from "@/components/command-menu";
|
||||
import type { ListItem } from "@/components/sidebar-content";
|
||||
import { contents } from "@/components/sidebar-content";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -18,7 +19,7 @@ export function DocsSidebar() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const branch = searchParams.get("branch");
|
||||
const { setOpen, openAI } = useCommandMenu();
|
||||
const { setOpenSearch } = useSearchContext();
|
||||
const [currentOpen, setCurrentOpen] = useState(0);
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
|
||||
@@ -75,30 +76,24 @@ export function DocsSidebar() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-4 lg:px-7 py-2.5 border-b border-foreground/5 text-[12px] uppercase tracking-wider text-foreground/55 hover:text-foreground/80 transition-colors"
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex w-full items-center gap-2 px-4 py-2.5 border-b border-foreground/5 text-sm text-foreground/55 hover:text-foreground/80 hover:bg-foreground/3 transition-colors"
|
||||
onClick={() => setOpenSearch(true)}
|
||||
>
|
||||
<Search className="size-3.5 shrink-0" />
|
||||
<Search className="size-4 shrink-0" />
|
||||
<span className="truncate">Search</span>
|
||||
<span
|
||||
className="ml-auto flex items-center gap-1 shrink-0 text-foreground/55 hover:text-foreground/70 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openAI();
|
||||
}}
|
||||
onKeyDown={() => {}}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Sparkles className="size-3" />
|
||||
<span className="text-[10px] normal-case tracking-normal">AI</span>
|
||||
</span>
|
||||
<kbd className="ml-auto inline-flex items-center gap-0.5 shrink-0 text-[10px] font-mono text-foreground/40 border border-foreground/10 rounded-md px-1.5 py-0.5">
|
||||
<span className="text-[11px]">⌘</span>K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
{/* Scrollable navigation area */}
|
||||
<nav
|
||||
ref={navRef}
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden pb-3 sidebar-scroll"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 2rem), transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<MotionConfig
|
||||
transition={{ duration: 0.35, type: "spring", bounce: 0 }}
|
||||
@@ -110,7 +105,6 @@ export function DocsSidebar() {
|
||||
type="button"
|
||||
className={cn(
|
||||
"border-b border-foreground/6 w-full text-left flex gap-2 items-center px-4 py-2.5 transition-colors",
|
||||
"lg:px-7",
|
||||
"font-medium text-sm tracking-wider",
|
||||
currentOpen === index
|
||||
? "text-foreground bg-foreground/3"
|
||||
@@ -152,6 +146,29 @@ export function DocsSidebar() {
|
||||
</div>
|
||||
</MotionConfig>
|
||||
</nav>
|
||||
|
||||
{/* Footer: GitHub + Theme Toggle */}
|
||||
<div className="flex items-center gap-1 p-2 border-t border-foreground/5 text-foreground/40">
|
||||
<a
|
||||
href="https://github.com/better-auth/better-auth"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center justify-center size-8 hover:text-foreground/70 hover:bg-foreground/5 transition-colors"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="size-4"
|
||||
>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
</a>
|
||||
<div className="ms-auto [&_button]:text-foreground/40 [&_button:hover]:text-foreground/70">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</motion.aside>
|
||||
);
|
||||
}
|
||||
@@ -193,7 +210,7 @@ function BranchSwitcher() {
|
||||
<div ref={ref} className="relative border-b border-foreground/10">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-4 lg:px-7 py-3 hover:bg-foreground/[0.02] transition-colors"
|
||||
className="flex w-full items-center gap-2 px-4 py-3 hover:bg-foreground/[0.02] transition-colors"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<GitBranch className="size-3.5 text-foreground/60 shrink-0" />
|
||||
@@ -372,16 +389,37 @@ function SidebarItemWithSubpages({
|
||||
transition={{ duration: 0.35, type: "spring", bounce: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="relative ml-7 lg:ml-11 pl-3 border-l border-foreground/20">
|
||||
{item.subpages.map((subpage) => (
|
||||
<SubpageLink
|
||||
key={subpage.href}
|
||||
href={`${subpage.href}${branchQuery}`}
|
||||
active={pathname === subpage.href}
|
||||
>
|
||||
{subpage.title}
|
||||
</SubpageLink>
|
||||
))}
|
||||
<div className="relative before:absolute before:left-[calc(1.75rem+0.75rem)] before:top-0 before:bottom-0 before:w-px before:bg-foreground/20">
|
||||
{item.subpages.map((subpage, i) => {
|
||||
if (subpage.group) {
|
||||
return (
|
||||
<div
|
||||
key={`subgroup-${subpage.title}-${i}`}
|
||||
className="flex flex-row items-center gap-2 pl-[calc(1.75rem+0.75rem+0.75rem)] pr-4 py-1.5 mt-1 first:mt-0"
|
||||
>
|
||||
<p className="text-[10px] text-foreground/45 uppercase tracking-wider">
|
||||
{subpage.title}
|
||||
</p>
|
||||
<div className="grow h-px bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!subpage.href) return null;
|
||||
return (
|
||||
<SubpageLink
|
||||
key={subpage.href}
|
||||
href={`${subpage.href}${branchQuery}`}
|
||||
active={pathname === subpage.href}
|
||||
icon={
|
||||
subpage.icon ? (
|
||||
<subpage.icon className="text-current" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{subpage.title}
|
||||
</SubpageLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -406,7 +444,7 @@ function SubpageLink({
|
||||
href={href}
|
||||
data-active={active || undefined}
|
||||
className={cn(
|
||||
"relative flex items-center pr-4 lg:pr-7 py-1 text-[13px] transition-all duration-150",
|
||||
"relative flex items-center gap-1 pl-[calc(1.75rem+0.75rem+0.75rem)] pr-4 py-1 text-[13px] transition-all duration-150",
|
||||
active
|
||||
? "text-foreground bg-foreground/6"
|
||||
: "text-foreground/55 hover:text-foreground/80 hover:bg-foreground/3",
|
||||
@@ -437,7 +475,7 @@ function SidebarLink({
|
||||
href={href}
|
||||
data-active={active || undefined}
|
||||
className={`
|
||||
relative flex items-center gap-2.5 px-4 lg:px-7 py-1 text-[14px] transition-all duration-150
|
||||
relative flex items-center gap-2.5 px-4 py-1 text-[14px] transition-all duration-150
|
||||
${
|
||||
active
|
||||
? "text-foreground bg-foreground/6"
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useCommandMenu } from "@/components/command-menu";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
|
||||
export function FloatingToolbar() {
|
||||
const { setOpen } = useCommandMenu();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.8, ease: "easeOut" }}
|
||||
className="fixed bottom-5 right-5 z-[100] flex items-center border border-foreground/[0.06] bg-background/60 backdrop-blur-md"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="group flex items-center justify-center size-9 text-foreground/40 hover:text-foreground/70 transition-colors"
|
||||
>
|
||||
<kbd className="flex items-center gap-0.5 text-[10px] font-mono">
|
||||
<span className="text-[11px]">⌘</span>K
|
||||
</kbd>
|
||||
<span className="sr-only">Command menu</span>
|
||||
</button>
|
||||
<span className="w-px h-3 bg-foreground/[0.08]" />
|
||||
<div className="flex items-center justify-center size-9 [&_button]:text-foreground/40 [&_button:hover]:text-foreground/70">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, MotionConfig, motion } from "framer-motion";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { ChevronDownIcon, Search } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import DarkPng from "../../public/branding/better-auth-logo-dark.png";
|
||||
import WhitePng from "../../public/branding/better-auth-logo-light.png";
|
||||
@@ -161,7 +162,7 @@ export function StaggeredNavFiles() {
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-1 px-4 lg:px-7 py-3 transition-colors duration-150"
|
||||
className="flex items-center gap-1 px-4 py-3 transition-colors duration-150"
|
||||
>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<LogoContextMenu
|
||||
@@ -191,11 +192,32 @@ export function StaggeredNavFiles() {
|
||||
className="flex items-center gap-1 px-4 py-3 transition-colors duration-150"
|
||||
>
|
||||
<Logo />
|
||||
<p className="select-none font-mono text-sm uppercase tracking-wider">
|
||||
<p className="select-none font-mono text-base uppercase">
|
||||
BETTER-AUTH.
|
||||
</p>
|
||||
</Link>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center gap-1 pr-2">
|
||||
{isDocs && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "k",
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
className="flex items-center justify-center size-8 text-foreground/50 hover:text-foreground/80 transition-colors"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center justify-center size-8 text-foreground/50 [&_button]:text-foreground/50 [&_button:hover]:text-foreground/80">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -216,7 +238,7 @@ export function StaggeredNavFiles() {
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-center px-4 py-3 text-foreground/75 dark:text-foreground/60 hover:text-foreground/85 transition-colors"
|
||||
className="flex items-center justify-center size-8 text-foreground/75 dark:text-foreground/60 hover:text-foreground/85 transition-colors"
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<svg
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
SearchItemType,
|
||||
SharedProps,
|
||||
} from "fumadocs-ui/components/dialog/search";
|
||||
import {
|
||||
SearchDialog,
|
||||
SearchDialogClose,
|
||||
SearchDialogContent,
|
||||
SearchDialogFooter,
|
||||
SearchDialogHeader,
|
||||
SearchDialogInput,
|
||||
SearchDialogList,
|
||||
SearchDialogListItem,
|
||||
SearchDialogOverlay,
|
||||
useSearch,
|
||||
} from "fumadocs-ui/components/dialog/search";
|
||||
import { ArrowRight, Search } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { Client } from "typesense";
|
||||
import { useTypesenseSearch } from "typesense-fumadocs-adapter/client";
|
||||
import { usePages } from "@/app/docs/provider";
|
||||
|
||||
const typesenseClient = (() => {
|
||||
const url = process.env.NEXT_PUBLIC_TYPESENSE_SERVER_URL;
|
||||
const apiKey = process.env.NEXT_PUBLIC_TYPESENSE_SEARCH_API_KEY;
|
||||
if (!url || !apiKey) {
|
||||
// Return a dummy client so the app works without Typesense configured
|
||||
return new Client({
|
||||
nodes: [{ host: "localhost", port: 8108, protocol: "http" }],
|
||||
apiKey: "dummy",
|
||||
});
|
||||
}
|
||||
let serverUrl: URL;
|
||||
try {
|
||||
serverUrl = new URL(url);
|
||||
} catch {
|
||||
return new Client({
|
||||
nodes: [{ host: "localhost", port: 8108, protocol: "http" }],
|
||||
apiKey: "dummy",
|
||||
});
|
||||
}
|
||||
return new Client({
|
||||
nodes: [
|
||||
{
|
||||
host: serverUrl.hostname,
|
||||
port:
|
||||
Number(serverUrl.port) ||
|
||||
(serverUrl.protocol === "https:" ? 443 : 80),
|
||||
protocol: serverUrl.protocol.replace(":", ""),
|
||||
},
|
||||
],
|
||||
apiKey,
|
||||
});
|
||||
})();
|
||||
|
||||
export default function CustomSearchDialog(props: SharedProps) {
|
||||
const { search, setSearch, query } = useTypesenseSearch({
|
||||
typesenseCollectionName: "better-auth-docs",
|
||||
client: typesenseClient!,
|
||||
});
|
||||
const pages = usePages();
|
||||
const router = useRouter();
|
||||
|
||||
const pageTreeAction = useMemo<SearchItemType | undefined>(() => {
|
||||
if (search.length === 0) return;
|
||||
|
||||
const normalized = search.toLowerCase();
|
||||
for (const page of pages) {
|
||||
if (!page.name.toLowerCase().includes(normalized)) continue;
|
||||
|
||||
return {
|
||||
id: "quick-action",
|
||||
type: "action",
|
||||
node: (
|
||||
<div className="inline-flex items-center gap-2 text-fd-muted-foreground">
|
||||
<ArrowRight className="size-4" />
|
||||
<p>
|
||||
Jump to{" "}
|
||||
<span className="font-medium text-fd-foreground">
|
||||
{page.name}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
onSelect: () => router.push(page.url),
|
||||
};
|
||||
}
|
||||
}, [router, search, pages]);
|
||||
|
||||
return (
|
||||
<SearchDialog
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
isLoading={query.isLoading}
|
||||
{...props}
|
||||
>
|
||||
<SearchDialogOverlay className="z-200" />
|
||||
<SearchDialogContent className="z-200">
|
||||
<SearchDialogHeader>
|
||||
<LoadingSearchIcon />
|
||||
<SearchDialogInput />
|
||||
<SearchDialogClose />
|
||||
</SearchDialogHeader>
|
||||
<SearchDialogList
|
||||
items={
|
||||
query.data !== "empty" || pageTreeAction
|
||||
? [
|
||||
...(pageTreeAction ? [pageTreeAction] : []),
|
||||
...(Array.isArray(query.data) ? query.data : []),
|
||||
]
|
||||
: null
|
||||
}
|
||||
Item={({ item, onClick }) => (
|
||||
<SearchDialogListItem
|
||||
item={item}
|
||||
onClick={onClick}
|
||||
className={
|
||||
item.type !== "action"
|
||||
? "max-h-24 [&>div:last-child]:line-clamp-2"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SearchDialogFooter>
|
||||
<span className="text-xs text-fd-muted-foreground">
|
||||
Search powered by{" "}
|
||||
<a
|
||||
href="https://typesense.org"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="underline hover:text-fd-foreground transition-colors"
|
||||
>
|
||||
Typesense
|
||||
</a>
|
||||
</span>
|
||||
</SearchDialogFooter>
|
||||
</SearchDialogContent>
|
||||
</SearchDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSearchIcon() {
|
||||
const { isLoading } = useSearch();
|
||||
|
||||
return (
|
||||
<Search
|
||||
className={
|
||||
isLoading
|
||||
? "size-5 animate-pulse text-foreground duration-200"
|
||||
: "size-5 text-fd-muted-foreground"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,45 @@
|
||||
import { findPath } from "fumadocs-core/page-tree";
|
||||
import type { DocumentRecord } from "typesense-fumadocs-adapter";
|
||||
import { source } from "@/lib/source";
|
||||
|
||||
export async function exportSearchIndexes() {
|
||||
const results: DocumentRecord[] = [];
|
||||
|
||||
function isBreadcrumbItem(item: unknown): item is string {
|
||||
return typeof item === "string" && item.length > 0;
|
||||
}
|
||||
|
||||
for (const page of source.getPages()) {
|
||||
let breadcrumbs: string[] | undefined;
|
||||
const pageTree = source.getPageTree(page.locale);
|
||||
const path = findPath(
|
||||
pageTree.children,
|
||||
(node) => node.type === "page" && node.url === page.url,
|
||||
);
|
||||
|
||||
if (path) {
|
||||
breadcrumbs = [];
|
||||
path.pop();
|
||||
if (isBreadcrumbItem(pageTree.name)) {
|
||||
breadcrumbs.push(pageTree.name);
|
||||
}
|
||||
for (const segment of path) {
|
||||
if (!isBreadcrumbItem(segment.name)) continue;
|
||||
breadcrumbs.push(segment.name);
|
||||
}
|
||||
}
|
||||
|
||||
const loaded = await page.data.load();
|
||||
|
||||
results.push({
|
||||
_id: page.url,
|
||||
structured: loaded.structuredData,
|
||||
url: page.url,
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
breadcrumbs,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -7,9 +7,11 @@
|
||||
"postinstall": "fumadocs-mdx",
|
||||
"dev": "next dev --turbopack --port 3000",
|
||||
"build": "next build",
|
||||
"postbuild": "pnpm sync-typesense",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "biome check . --fix --unsafe"
|
||||
"lint:fix": "biome check . --fix --unsafe",
|
||||
"sync-typesense": "node scripts/sync-typesense.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.44",
|
||||
@@ -71,6 +73,8 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"three": "^0.183.1",
|
||||
"typesense": "^3.0.2",
|
||||
"typesense-fumadocs-adapter": "^0.3.0",
|
||||
"unist-util-visit": "^5.1.0",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
@@ -81,6 +85,7 @@
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/three": "^0.183.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
"typescript": "5.9.3"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import "dotenv/config";
|
||||
import * as fs from "node:fs";
|
||||
import { Client } from "typesense";
|
||||
import type { DocumentRecord } from "typesense-fumadocs-adapter";
|
||||
import { sync } from "typesense-fumadocs-adapter";
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_TYPESENSE_SERVER_URL;
|
||||
const adminKey = process.env.TYPESENSE_ADMIN_API_KEY;
|
||||
|
||||
if (!url || !adminKey) {
|
||||
console.log("Typesense env vars not set, skipping sync.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!process.env.VERCEL) {
|
||||
console.log("Not running on Production build, skipping sync.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (process.env.VERCEL_GIT_COMMIT_REF !== "main") {
|
||||
console.log(
|
||||
`Branch is "${process.env.VERCEL_GIT_COMMIT_REF}", skipping sync.`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const filePath = ".next/server/app/api/docs/static.json.body";
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log("Build output not found, run 'pnpm build' first. Skipping sync.");
|
||||
process.exit(0);
|
||||
}
|
||||
const content = fs.readFileSync(filePath);
|
||||
const records = JSON.parse(content.toString()) as DocumentRecord[];
|
||||
|
||||
const serverUrl = new URL(url);
|
||||
|
||||
const client = new Client({
|
||||
nodes: [
|
||||
{
|
||||
host: serverUrl.hostname,
|
||||
port:
|
||||
Number(serverUrl.port) || (serverUrl.protocol === "https:" ? 443 : 80),
|
||||
protocol: serverUrl.protocol.replace(":", ""),
|
||||
},
|
||||
],
|
||||
apiKey: adminKey,
|
||||
connectionTimeoutSeconds: 30,
|
||||
});
|
||||
|
||||
void sync(client, {
|
||||
typesenseCollectionName: "better-auth-docs",
|
||||
documents: records,
|
||||
})
|
||||
.then(() => {
|
||||
console.log(`search updated: ${records.length} records`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to sync Typesense index:", error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Generated
+1044
-263
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user