|
| 1 | +import { useState, useEffect, useRef } from "react"; |
| 2 | + |
| 3 | +interface DocEntry { |
| 4 | + title: string; |
| 5 | + href: string; |
| 6 | + headings: string[]; |
| 7 | +} |
| 8 | + |
| 9 | +const docs: DocEntry[] = [ |
| 10 | + { title: "Overview", href: "/docs", headings: ["Getting started", "API reference", "Self-hosting"] }, |
| 11 | + { title: "Concepts", href: "/docs/concepts", headings: ["Collection", "Version", "Record", "File", "Accounts"] }, |
| 12 | + { title: "Quickstart", href: "/docs/quickstart", headings: ["Create an account", "Create an API key", "Create a collection", "Push a version", "Read it back", "Push an update", "Diff versions", "Working with files", "Next steps"] }, |
| 13 | + { title: "Integration Guide", href: "/docs/integration", headings: ["What is Underlay?", "Core Concepts", "Authentication", "The Push Flow", "Record Format", "First Push Example", "Mapping a SQL Database", "Versioning", "API Reference", "Error Handling", "Source Code"] }, |
| 14 | + { title: "Self-hosting", href: "/docs/self-host", headings: ["Requirements", "Quick start with Docker", "Environment variables", "Production deployment", "Secrets management", "CI/CD", "Backups", "Reverse proxy"] }, |
| 15 | + { title: "Accounts API", href: "/docs/api/accounts", headings: ["Authentication", "POST /api/accounts/signup", "POST /api/accounts/login", "POST /api/accounts/logout", "GET /api/accounts/me", "GET /api/accounts/:slug", "POST /api/accounts/keys", "GET /api/accounts/keys", "DELETE /api/accounts/keys/:id"] }, |
| 16 | + { title: "Collections API", href: "/docs/api/collections", headings: ["GET /api/collections", "POST /api/accounts/:owner/collections", "GET /api/collections/:owner/:slug", "PATCH /api/collections/:owner/:slug", "DELETE /api/collections/:owner/:slug", "GET /api/accounts/:owner/collections"] }, |
| 17 | + { title: "Versions API", href: "/docs/api/versions", headings: ["POST /api/collections/:owner/:slug/versions", "GET /api/collections/:owner/:slug/versions", "GET /api/collections/:owner/:slug/versions/latest", "GET /api/collections/:owner/:slug/versions/:n", "GET /api/collections/:owner/:slug/versions/:n/records", "GET /api/collections/:owner/:slug/versions/:n/manifest", "GET /api/collections/:owner/:slug/versions/:n/diff"] }, |
| 18 | + { title: "Files API", href: "/docs/api/files", headings: ["HEAD /api/collections/:owner/:slug/files/:hash", "GET /api/collections/:owner/:slug/files/:hash", "PUT /api/collections/:owner/:slug/files/:hash", "File references in records"] }, |
| 19 | +]; |
| 20 | + |
| 21 | +export default function DocsSearch() { |
| 22 | + const [query, setQuery] = useState(""); |
| 23 | + const [showResults, setShowResults] = useState(false); |
| 24 | + const rootRef = useRef<HTMLDivElement>(null); |
| 25 | + |
| 26 | + const q = query.trim().toLowerCase(); |
| 27 | + |
| 28 | + const matches: { title: string; href: string; context?: string }[] = []; |
| 29 | + if (q) { |
| 30 | + for (const doc of docs) { |
| 31 | + const titleMatch = doc.title.toLowerCase().includes(q); |
| 32 | + const headingMatches = doc.headings.filter((h) => h.toLowerCase().includes(q)); |
| 33 | + if (titleMatch) { |
| 34 | + matches.push({ title: doc.title, href: doc.href }); |
| 35 | + } |
| 36 | + for (const h of headingMatches) { |
| 37 | + if (!titleMatch || headingMatches.length > 0) { |
| 38 | + const slug = h.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); |
| 39 | + matches.push({ title: doc.title, href: `${doc.href}#${slug}`, context: h }); |
| 40 | + } |
| 41 | + } |
| 42 | + } |
| 43 | + } |
| 44 | + |
| 45 | + useEffect(() => { |
| 46 | + function handleClickOutside(e: MouseEvent) { |
| 47 | + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { |
| 48 | + setShowResults(false); |
| 49 | + } |
| 50 | + } |
| 51 | + document.addEventListener("click", handleClickOutside); |
| 52 | + return () => document.removeEventListener("click", handleClickOutside); |
| 53 | + }, []); |
| 54 | + |
| 55 | + function handleKeyDown(e: React.KeyboardEvent) { |
| 56 | + if (e.key === "Escape") { |
| 57 | + setShowResults(false); |
| 58 | + (e.target as HTMLInputElement).blur(); |
| 59 | + } |
| 60 | + } |
| 61 | + |
| 62 | + return ( |
| 63 | + <div ref={rootRef} className="docs-search-box"> |
| 64 | + <input |
| 65 | + type="text" |
| 66 | + placeholder="Search docs..." |
| 67 | + autoComplete="off" |
| 68 | + className="w-full border border-rule bg-parchment px-2.5 py-1.5 text-xs placeholder:text-ink-muted/50 focus:outline-none focus:border-ink-muted" |
| 69 | + value={query} |
| 70 | + onChange={(e) => { |
| 71 | + setQuery(e.target.value); |
| 72 | + setShowResults(true); |
| 73 | + }} |
| 74 | + onKeyDown={handleKeyDown} |
| 75 | + /> |
| 76 | + {showResults && q && ( |
| 77 | + <div id="docs-search-results"> |
| 78 | + {matches.length === 0 ? ( |
| 79 | + <div className="docs-search-empty">No results</div> |
| 80 | + ) : ( |
| 81 | + matches.slice(0, 12).map((m, i) => ( |
| 82 | + <a key={i} href={m.href} className="docs-search-result"> |
| 83 | + <span className="docs-search-result-title">{m.title}</span> |
| 84 | + {m.context && <span className="docs-search-result-context">{m.context}</span>} |
| 85 | + </a> |
| 86 | + )) |
| 87 | + )} |
| 88 | + </div> |
| 89 | + )} |
| 90 | + </div> |
| 91 | + ); |
| 92 | +} |
0 commit comments