Skip to content
113 linesCodeBlameRaw
1import type { DocSearchHit } from "@g1t/contracts";
2import { FolderGit2, Search } from "lucide-react";
3import { Form, Link, data, useSubmit } from "react-router";
4
5import type { Route } from "./+types/search";
6import { useDocsData } from "../../../components/docs/actions";
7import { PageIcon } from "../../../components/docs/sidebar";
8import { EmptyState, TimeAgo } from "../../../components/ui";
9import { SelectField } from "../../../components/ui/select";
10import { snippetParts } from "../../../lib/docs";
11import { page } from "../../../lib/meta";
12import { docs } from "../../../lib/services.server";
13import { requireUser, roleIn } from "../../../lib/session.server";
14
15export function meta({ params, ...args }: Route.MetaArgs) {
16 return page(args, { title: `Search docs · ${params.owner} · g1t` });
17}
18
19export 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 */
37export 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&apos; 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 &ldquo;{q}&rdquo;.</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}