| 1 | import type { DocSearchHit } from "@g1t/contracts"; |
| 2 | import { FolderGit2, Search } from "lucide-react"; |
| 3 | import { Form, Link, data, useSubmit } from "react-router"; |
| 4 | |
| 5 | import type { Route } from "./+types/search"; |
| 6 | import { useDocsData } from "../../../components/docs/actions"; |
| 7 | import { PageIcon } from "../../../components/docs/sidebar"; |
| 8 | import { EmptyState, TimeAgo } from "../../../components/ui"; |
| 9 | import { SelectField } from "../../../components/ui/select"; |
| 10 | import { snippetParts } from "../../../lib/docs"; |
| 11 | import { page } from "../../../lib/meta"; |
| 12 | import { docs } from "../../../lib/services.server"; |
| 13 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 14 | |
| 15 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 16 | return page(args, { title: `Search docs · ${params.owner} · g1t` }); |
| 17 | } |
| 18 | |
| 19 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 20 | const viewer = requireUser(context, request); |
| 21 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); |
| 22 | const url = new URL(request.url); |
| 23 | const q = url.searchParams.get("q") ?? ""; |
| 24 | const space = url.searchParams.get("space") || null; |
| 25 | const project = url.searchParams.get("project") || null; |
| 26 | const found = q.trim() |
| 27 | ? await docs.search(params.owner.toLowerCase(), viewer, { query: q, space_id: space, project, limit: 50, mode: "hybrid" }).catch(() => null) |
| 28 | : null; |
| 29 | return { q, space, project, hits: found?.ok ? found.value : q.trim() ? null : ([] as DocSearchHit[]) }; |
| 30 | } |
| 31 | |
| 32 | /** |
| 33 | * Every page the viewer can read, and the projects' docs shown in Docs, |
| 34 | * by words and by meaning at once; by space and by project. Each hit shows |
| 35 | * the passage that matched, under its heading. |
| 36 | */ |
| 37 | export default function DocsSearch({ loaderData, params }: Route.ComponentProps) { |
| 38 | const slug = params.owner.toLowerCase(); |
| 39 | const { q, space, project, hits } = loaderData; |
| 40 | const layout = useDocsData(); |
| 41 | const submit = useSubmit(); |
| 42 | const spaces = layout?.sidebar?.spaces ?? []; |
| 43 | const projects = [...new Set(spaces.flatMap((s) => s.projects))].sort(); |
| 44 | const refresh = () => submit(document.getElementById("docs-search") as HTMLFormElement); |
| 45 | return ( |
| 46 | <div className="mx-auto max-w-3xl"> |
| 47 | <h1 className="text-xl font-semibold tracking-tight">Search docs</h1> |
| 48 | <Form method="get" id="docs-search" className="mt-4 flex flex-wrap gap-2"> |
| 49 | <label className="flex h-10 min-w-0 grow items-center gap-2 rounded-md border border-line bg-surface px-3 focus-within:border-accent/60"> |
| 50 | <Search size={16} className="text-faint" aria-hidden="true" /> |
| 51 | <input name="q" defaultValue={q} autoFocus placeholder="Words, or a question" aria-label="Search" className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint" /> |
| 52 | </label> |
| 53 | <SelectField |
| 54 | name="space" |
| 55 | aria-label="Space" |
| 56 | value={space ?? ""} |
| 57 | afterChange={refresh} |
| 58 | options={[{ value: "", label: "Every space" }, ...spaces.map((s) => ({ value: s.id, label: s.name }))]} |
| 59 | className="h-10 min-w-40" |
| 60 | /> |
| 61 | {projects.length > 0 && ( |
| 62 | <SelectField name="project" aria-label="Project" value={project ?? ""} afterChange={refresh} options={[{ value: "", label: "Any project" }, ...projects.map((p) => ({ value: p, label: p }))]} className="h-10 min-w-40" /> |
| 63 | )} |
| 64 | </Form> |
| 65 | <div className="mt-6"> |
| 66 | {hits === null ? ( |
| 67 | <EmptyState title="Search didn't answer">Try again in a moment.</EmptyState> |
| 68 | ) : !q.trim() ? ( |
| 69 | <p className="text-sm text-muted"> |
| 70 | Search every space you can read, and the projects' docs shown in Docs, by their words and by what they mean: ask a question and the passage that answers it comes up even when it words it differently. Agents recall from |
| 71 | Docs the same way, and only from what everyone they answer can read. |
| 72 | </p> |
| 73 | ) : hits.length === 0 ? ( |
| 74 | <EmptyState title="Nothing found">No page you can read matches “{q}”.</EmptyState> |
| 75 | ) : ( |
| 76 | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> |
| 77 | {hits.map((hit) => ( |
| 78 | <li key={hit.id}> |
| 79 | <Link to={hit.path} className="block px-4 py-3 transition-colors hover:bg-raised"> |
| 80 | <span className="flex items-center gap-2"> |
| 81 | {hit.repo_file ? <FolderGit2 size={15} className="shrink-0 text-faint" aria-hidden="true" /> : <PageIcon icon={hit.icon} />} |
| 82 | <span className="truncate text-sm font-medium">{hit.title || "Untitled"}</span> |
| 83 | <span className="ml-auto shrink-0 text-xs text-faint"> |
| 84 | {hit.space_name} · <TimeAgo at={hit.updated_at} /> |
| 85 | </span> |
| 86 | </span> |
| 87 | {hit.heading && <span className="mt-0.5 block truncate text-xs font-medium text-muted">{hit.heading}</span>} |
| 88 | <span className="mt-1 line-clamp-2 block text-xs leading-relaxed text-muted"> |
| 89 | {snippetParts(hit.snippet).map((part, i) => |
| 90 | part.match ? ( |
| 91 | <mark key={i} className="rounded bg-accent/20 px-0.5 text-fg"> |
| 92 | {part.text} |
| 93 | </mark> |
| 94 | ) : ( |
| 95 | <span key={i}>{part.text}</span> |
| 96 | ), |
| 97 | )} |
| 98 | </span> |
| 99 | {hit.projects.length > 0 && <span className="mt-1 block text-[0.6875rem] text-faint">{hit.projects.join(" · ")}</span>} |
| 100 | </Link> |
| 101 | </li> |
| 102 | ))} |
| 103 | </ul> |
| 104 | )} |
| 105 | </div> |
| 106 | <p className="mt-4 text-xs text-faint"> |
| 107 | <Link to={`/${slug}/-/docs`} className="hover:text-fg"> |
| 108 | Back to Docs |
| 109 | </Link> |
| 110 | </p> |
| 111 | </div> |
| 112 | ); |
| 113 | } |