Skip to content
195 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Docs: a workspace knowledge base people and agents write together1import type { DocTemplate, DocsHome } from "@g1t/contracts";
2import { BookOpen, FilePlus2, Plus, Search } from "lucide-react";
3import { useState } from "react";
4import { Form, Link, data, useNavigate, useSubmit } from "react-router";
5
6import type { Route } from "./+types/home";
7import { docsRequest, useDocsData } from "../../../components/docs/actions";
8import { PageCard, SectionTitle, SpaceCard, TemplateCard } from "../../../components/docs/parts";
9import { DocsSidebar } from "../../../components/docs/sidebar";
10import { ButtonLink, EmptyState, ErrorText } from "../../../components/ui";
11import { SelectField } from "../../../components/ui/select";
12import { canDo } from "../../../lib/docs";
13import { page } from "../../../lib/meta";
14import { docs } from "../../../lib/services.server";
15import { requireUser, roleIn } from "../../../lib/session.server";
16import { BUILTIN_TEMPLATE_IDS } from "../../../components/docs/templates";
17
18export function meta({ params, ...args }: Route.MetaArgs) {
19 return page(args, { title: `Docs · ${params.owner} · g1t` });
20}
21
22export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ home: DocsHome | null; templates: DocTemplate[] }> {
23 const viewer = requireUser(context, request);
24 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
25 const slug = params.owner.toLowerCase();
26 const project = new URL(request.url).searchParams.get("project");
27 const [home, templates] = await Promise.all([
28 docs
29 .home(slug, viewer, { project })
30 .then((r) => (r.ok ? r.value : null))
31 .catch(() => null),
32 docs
33 .templates(slug, viewer)
34 .then((r) => (r.ok ? r.value : []))
35 .catch(() => []),
36 ]);
37 return { home, templates };
38}
39
40/** Docs' home: what changed lately, your pages, the spaces, templates to start from; filtered by project. */
41export default function DocsHomePage({ loaderData, params }: Route.ComponentProps) {
42 const slug = params.owner.toLowerCase();
43 const { home, templates } = loaderData;
44 const layout = useDocsData();
45 const navigate = useNavigate();
46 const submit = useSubmit();
47 const [error, setError] = useState<string | null>(null);
48 const general = layout?.sidebar?.spaces.find((s) => s.is_default) ?? layout?.sidebar?.spaces.find((s) => canDo(s.viewer_role, "edit"));
49 const spacesById = new Map((home?.spaces ?? []).map((s) => [s.id, s]));
50 const create = async (template?: string) => {
51 if (!general) return;
52 const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: general.id, template_id: template ?? null } });
53 if (made.ok) navigate(made.value.path);
54 else setError(made.error.message);
55 };
56 if (!home) {
57 return (
58 <div className="mx-auto max-w-3xl">
59 <EmptyState title="Docs didn't answer">Try again in a moment.</EmptyState>
60 </div>
61 );
62 }
63 const featured = templates.filter((t) => BUILTIN_TEMPLATE_IDS.includes(t.id)).slice(0, 4);
64 return (
65 <div className="mx-auto max-w-5xl">
66 {/* On a phone, the spaces and pages first: the sidebar as the page. */}
67 <div className="-mx-4 -mt-6 mb-6 border-b border-line md:hidden">
68 <div className="h-[60dvh]">
69 <DocsSidebar slug={slug} />
70 </div>
71 </div>
72 <div className="flex flex-wrap items-end justify-between gap-4">
73 <div>
74 <div className="flex items-center gap-2 text-accent">
75 <BookOpen size={18} />
76 <span className="text-xs font-medium tracking-wide uppercase">Docs</span>
77 </div>
78 <h1 className="mt-2 text-2xl font-semibold tracking-tight">What your workspace knows</h1>
79 <p className="mt-1 max-w-xl text-sm text-muted">Specs, runbooks, decisions and onboarding, written together with your agents. Everything here is searchable, and agents read it before they work.</p>
80 </div>
81 <div className="flex items-center gap-2">
82 <ButtonLink to={`/${slug}/-/docs/new`} variant="quiet">
83 <Plus size={15} /> New space
84 </ButtonLink>
85 {general && (
86 <button type="button" onClick={() => create()} className="inline-flex h-9 items-center gap-1.5 rounded-md bg-accent px-3 text-sm font-medium text-bg transition-colors hover:bg-accent-hover">
87 <FilePlus2 size={15} /> New page
88 </button>
89 )}
90 </div>
91 </div>
92 {error && (
93 <div className="mt-3">
94 <ErrorText>{error}</ErrorText>
95 </div>
96 )}
97
98 <div className="mt-6 flex flex-wrap items-center gap-2">
99 <Form method="get" action={`/${slug}/-/docs/search`} className="flex h-9 min-w-0 grow items-center gap-2 rounded-md border border-line bg-surface px-3 focus-within:border-accent/60 sm:max-w-md">
100 <Search size={15} className="text-faint" aria-hidden="true" />
101 <input name="q" placeholder="Search every page you can read" aria-label="Search docs" className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint" />
102 {home.project && <input type="hidden" name="project" value={home.project} />}
103 </Form>
104 {home.projects.length > 0 && (
105 <Form method="get" id="docs-project">
106 <SelectField
107 name="project"
108 aria-label="Filter by project"
109 value={home.project ?? ""}
110 afterChange={() => submit(document.getElementById("docs-project") as HTMLFormElement)}
111 options={[{ value: "", label: "All projects" }, ...home.projects.map((p) => ({ value: p, label: p }))]}
112 className="h-9 min-w-48"
113 />
114 </Form>
115 )}
116 </div>
117
118 <section className="mt-8">
119 <SectionTitle>{home.project ? `Recently edited · ${home.project}` : "Recently edited"}</SectionTitle>
120 {home.recent.length ? (
121 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
122 {home.recent.map((p) => (
123 <PageCard key={p.id} page={p} space={spacesById.get(p.space_id)} />
124 ))}
125 </div>
126 ) : (
127 <EmptyState title={home.project ? "No pages about this project yet" : "No pages yet"}>
128 {home.project ? "Link a space or a page to this project from its settings." : "Start one from a template below, or with New page."}
129 </EmptyState>
130 )}
131 </section>
132
133 {home.mine.length > 0 && (
134 <section className="mt-10">
135 <SectionTitle>Your pages</SectionTitle>
136 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
137 {home.mine.map((p) => (
138 <li key={p.id}>
139 <Link to={p.path} prefetch="intent" className="flex items-center gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-raised">
140 <span className="w-5 text-center">{p.icon ?? "📄"}</span>
141 <span className="min-w-0 grow truncate">{p.title || "Untitled"}</span>
142 <span className="hidden text-xs text-faint sm:inline">{spacesById.get(p.space_id)?.name}</span>
143 </Link>
144 </li>
145 ))}
146 </ul>
147 </section>
148 )}
149
150 <section className="mt-10">
151 <SectionTitle
152 action={
153 <Link to={`/${slug}/-/docs/new`} className="text-xs text-muted hover:text-fg">
154 New space
155 </Link>
156 }
157 >
158 Spaces
159 </SectionTitle>
160 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
161 {home.spaces.map((s) => (
162 <SpaceCard key={s.id} slug={slug} space={s} />
163 ))}
164 </div>
165 </section>
166
167 {general && featured.length > 0 && (
168 <section className="mt-10 mb-6">
169 <SectionTitle
170 action={
171 <Link to={`/${slug}/-/docs/templates`} className="text-xs text-muted hover:text-fg">
172 All templates
173 </Link>
174 }
175 >
176 Start from a template
177 </SectionTitle>
178 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
179 {featured.map((t) => (
180 <TemplateCard
181 key={t.id}
182 template={t}
183 action={
184 <button type="button" onClick={() => create(t.id)} className="text-xs font-medium text-accent hover:underline">
185 Use in {general.name}
186 </button>
187 }
188 />
189 ))}
190 </div>
191 </section>
192 )}
193 </div>
194 );
195}