Skip to content
105 linesCodeBlameRaw
1import type { DocSearchHit } from "@g1t/contracts";
2import { 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 }).catch(() => null)
28 : null;
29 return { q, space, project, hits: found?.ok ? found.value : q.trim() ? null : ([] as DocSearchHit[]) };
30}
31
32/** Full text over every page the viewer can read; by space and by project. */
33export default function DocsSearch({ loaderData, params }: Route.ComponentProps) {
34 const slug = params.owner.toLowerCase();
35 const { q, space, project, hits } = loaderData;
36 const layout = useDocsData();
37 const submit = useSubmit();
38 const spaces = layout?.sidebar?.spaces ?? [];
39 const projects = [...new Set(spaces.flatMap((s) => s.projects))].sort();
40 const refresh = () => submit(document.getElementById("docs-search") as HTMLFormElement);
41 return (
42 <div className="mx-auto max-w-3xl">
43 <h1 className="text-xl font-semibold tracking-tight">Search docs</h1>
44 <Form method="get" id="docs-search" className="mt-4 flex flex-wrap gap-2">
45 <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">
46 <Search size={16} className="text-faint" aria-hidden="true" />
47 <input name="q" defaultValue={q} autoFocus placeholder="Words in a title or a page" aria-label="Search" className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint" />
48 </label>
49 <SelectField
50 name="space"
51 aria-label="Space"
52 value={space ?? ""}
53 afterChange={refresh}
54 options={[{ value: "", label: "Every space" }, ...spaces.map((s) => ({ value: s.id, label: s.name }))]}
55 className="h-10 min-w-40"
56 />
57 {projects.length > 0 && (
58 <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" />
59 )}
60 </Form>
61 <div className="mt-6">
62 {hits === null ? (
63 <EmptyState title="Search didn't answer">Try again in a moment.</EmptyState>
64 ) : !q.trim() ? (
65 <p className="text-sm text-muted">Search titles and text across every space you can read. Agents search the same way, and only see what the people they answer can see.</p>
66 ) : hits.length === 0 ? (
67 <EmptyState title="Nothing found">No page you can read matches &ldquo;{q}&rdquo;.</EmptyState>
68 ) : (
69 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
70 {hits.map((hit) => (
71 <li key={hit.id}>
72 <Link to={hit.path} className="block px-4 py-3 transition-colors hover:bg-raised">
73 <span className="flex items-center gap-2">
74 <PageIcon icon={hit.icon} />
75 <span className="truncate text-sm font-medium">{hit.title || "Untitled"}</span>
76 <span className="ml-auto shrink-0 text-xs text-faint">
77 {hit.space_name} · <TimeAgo at={hit.updated_at} />
78 </span>
79 </span>
80 <span className="mt-1 line-clamp-2 block text-xs leading-relaxed text-muted">
81 {snippetParts(hit.snippet).map((part, i) =>
82 part.match ? (
83 <mark key={i} className="rounded bg-accent/20 px-0.5 text-fg">
84 {part.text}
85 </mark>
86 ) : (
87 <span key={i}>{part.text}</span>
88 ),
89 )}
90 </span>
91 {hit.projects.length > 0 && <span className="mt-1 block text-[0.6875rem] text-faint">{hit.projects.join(" · ")}</span>}
92 </Link>
93 </li>
94 ))}
95 </ul>
96 )}
97 </div>
98 <p className="mt-4 text-xs text-faint">
99 <Link to={`/${slug}/-/docs`} className="hover:text-fg">
100 Back to Docs
101 </Link>
102 </p>
103 </div>
104 );
105}