| 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 }).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. */ |
| 33 | export 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, and the projects' docs shown in Docs. 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 “{q}”.</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 | {hit.repo_file ? <FolderGit2 size={15} className="shrink-0 text-faint" aria-hidden="true" /> : <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 | } |