Files
better-auth/docs/components/search-dialog.tsx
2026-04-01 13:38:37 +00:00

164 lines
3.9 KiB
TypeScript

"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!,
/**
* Non-legacy mode leaves raw <mark> tags in content at the moment,
* which renders as plain text in fumadocs-ui
*/
legacy: true,
});
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-400"
: "size-5 text-fd-muted-foreground"
}
/>
);
}