Skip to content
244 linesCodeBlameRaw
1import type { DocTemplate, DocsHome } from "@g1t/contracts";
2import { AlertTriangle, BookOpen, FilePlus2, FolderGit2, Plus, Search } from "lucide-react";
3import { useState } from "react";
4import { Form, Link, data, useNavigate, useRevalidator, useSubmit } from "react-router";
5
6import type { Route } from "./+types/home";
7import { docsRequest, useDocsData } from "../../../components/docs/actions";
8import { RepoDocsDialog } from "../../../components/docs/code";
9import { PageCard, SectionTitle, SpaceCard, TemplateCard } from "../../../components/docs/parts";
10import { DocsSidebar } from "../../../components/docs/sidebar";
11import { ButtonLink, EmptyState, ErrorText } from "../../../components/ui";
12import { SelectField } from "../../../components/ui/select";
13import { canDo } from "../../../lib/docs";
14import { page } from "../../../lib/meta";
15import { docs } from "../../../lib/services.server";
16import { requireUser, roleIn } from "../../../lib/session.server";
17import { BUILTIN_TEMPLATE_IDS } from "../../../components/docs/templates";
18
19export function meta({ params, ...args }: Route.MetaArgs) {
20 return page(args, { title: `Docs · ${params.owner} · g1t` });
21}
22
23export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ home: DocsHome | null; templates: DocTemplate[] }> {
24 const viewer = requireUser(context, request);
25 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
26 const slug = params.owner.toLowerCase();
27 const project = new URL(request.url).searchParams.get("project");
28 const [home, templates] = await Promise.all([
29 docs
30 .home(slug, viewer, { project })
31 .then((r) => (r.ok ? r.value : null))
32 .catch(() => null),
33 docs
34 .templates(slug, viewer)
35 .then((r) => (r.ok ? r.value : []))
36 .catch(() => []),
37 ]);
38 return { home, templates };
39}
40
41/** Docs' home: what changed lately, your pages, the spaces, templates to start from; filtered by project. */
42export default function DocsHomePage({ loaderData, params }: Route.ComponentProps) {
43 const slug = params.owner.toLowerCase();
44 const { home, templates } = loaderData;
45 const layout = useDocsData();
46 const navigate = useNavigate();
47 const submit = useSubmit();
48 const [error, setError] = useState<string | null>(null);
49 const [addingRepo, setAddingRepo] = useState(false);
50 const { revalidate } = useRevalidator();
51 const general = layout?.sidebar?.spaces.find((s) => s.is_default) ?? layout?.sidebar?.spaces.find((s) => canDo(s.viewer_role, "edit"));
52 const spacesById = new Map((home?.spaces ?? []).map((s) => [s.id, s]));
53 // What a new page is being started from ("" for a blank one), while it is.
54 const [starting, setStarting] = useState<string | null>(null);
55 const create = async (template?: string) => {
56 if (!general || starting != null) return;
57 setStarting(template ?? "");
58 setError(null);
59 const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: general.id, template_id: template ?? null } });
60 if (made.ok) {
61 await navigate(made.value.path);
62 return;
63 }
64 setStarting(null);
65 setError(made.error.message);
66 };
67 if (!home) {
68 return (
69 <div className="mx-auto max-w-3xl">
70 <EmptyState title="Docs didn't answer">Try again in a moment.</EmptyState>
71 </div>
72 );
73 }
74 const featured = templates.filter((t) => BUILTIN_TEMPLATE_IDS.includes(t.id)).slice(0, 4);
75 return (
76 <div className="mx-auto max-w-5xl">
77 {/* On a phone, the spaces and pages first: the sidebar as the page. */}
78 <div className="-mx-4 -mt-6 mb-6 border-b border-line md:hidden">
79 <div className="h-[60dvh]">
80 <DocsSidebar slug={slug} />
81 </div>
82 </div>
83 <div className="flex flex-wrap items-end justify-between gap-4">
84 <div>
85 <div className="flex items-center gap-2 text-accent">
86 <BookOpen size={18} />
87 <span className="text-xs font-medium tracking-wide uppercase">Docs</span>
88 </div>
89 <h1 className="mt-2 text-2xl font-semibold tracking-tight">What your workspace knows</h1>
90 <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>
91 </div>
92 <div className="flex items-center gap-2">
93 <button type="button" onClick={() => setAddingRepo(true)} className="inline-flex h-9 items-center gap-1.5 rounded-md border border-line px-3 text-sm text-fg/80 transition-colors hover:border-line-strong hover:bg-surface hover:text-fg">
94 <FolderGit2 size={15} /> Show a project&apos;s docs
95 </button>
96 <ButtonLink to={`/${slug}/-/docs/new`} variant="quiet">
97 <Plus size={15} /> New space
98 </ButtonLink>
99 {general && (
100 <button
101 type="button"
102 disabled={starting != null}
103 aria-busy={starting === ""}
104 onClick={() => create()}
105 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 disabled:opacity-60"
106 >
107 <FilePlus2 size={15} /> {starting === "" ? "Starting…" : "New page"}
108 </button>
109 )}
110 </div>
111 </div>
112 {error && (
113 <div className="mt-3">
114 <ErrorText>{error}</ErrorText>
115 </div>
116 )}
117
118 <div className="mt-6 flex flex-wrap items-center gap-2">
119 <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">
120 <Search size={15} className="text-faint" aria-hidden="true" />
121 <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" />
122 {home.project && <input type="hidden" name="project" value={home.project} />}
123 </Form>
124 {home.projects.length > 0 && (
125 <Form method="get" id="docs-project">
126 <SelectField
127 name="project"
128 aria-label="Filter by project"
129 value={home.project ?? ""}
130 afterChange={() => submit(document.getElementById("docs-project") as HTMLFormElement)}
131 options={[{ value: "", label: "All projects" }, ...home.projects.map((p) => ({ value: p, label: p }))]}
132 className="h-9 min-w-48"
133 />
134 </Form>
135 )}
136 </div>
137
138 {home.stale.length > 0 && (
139 <section className="mt-8">
140 <SectionTitle
141 action={
142 <Link to={`/${slug}/-/docs/stale`} className="text-xs text-muted hover:text-fg">
143 All of them
144 </Link>
145 }
146 >
147 <span className="inline-flex items-center gap-1.5">
148 <AlertTriangle size={13} className="text-warn" /> Possibly out of date
149 </span>
150 </SectionTitle>
151 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
152 {home.stale.map((p) => (
153 <PageCard key={p.id} page={p} space={spacesById.get(p.space_id)} />
154 ))}
155 </div>
156 </section>
157 )}
158
159 <section className="mt-8">
160 <SectionTitle>{home.project ? `Recently edited · ${home.project}` : "Recently edited"}</SectionTitle>
161 {home.recent.length ? (
162 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
163 {home.recent.map((p) => (
164 <PageCard key={p.id} page={p} space={spacesById.get(p.space_id)} />
165 ))}
166 </div>
167 ) : (
168 <EmptyState title={home.project ? "No pages about this project yet" : "No pages yet"}>
169 {home.project ? "Link a space or a page to this project from its settings." : "Start one from a template below, or with New page."}
170 </EmptyState>
171 )}
172 </section>
173
174 {home.mine.length > 0 && (
175 <section className="mt-10">
176 <SectionTitle>Your pages</SectionTitle>
177 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
178 {home.mine.map((p) => (
179 <li key={p.id}>
180 <Link to={p.path} prefetch="intent" className="flex items-center gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-raised">
181 <span className="w-5 text-center">{p.icon ?? "📄"}</span>
182 <span className="min-w-0 grow truncate">{p.title || "Untitled"}</span>
183 <span className="hidden text-xs text-faint sm:inline">{spacesById.get(p.space_id)?.name}</span>
184 </Link>
185 </li>
186 ))}
187 </ul>
188 </section>
189 )}
190
191 <section className="mt-10">
192 <SectionTitle
193 action={
194 <Link to={`/${slug}/-/docs/new`} className="text-xs text-muted hover:text-fg">
195 New space
196 </Link>
197 }
198 >
199 Spaces
200 </SectionTitle>
201 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
202 {home.spaces.map((s) => (
203 <SpaceCard key={s.id} slug={slug} space={s} />
204 ))}
205 </div>
206 </section>
207
208 <RepoDocsDialog slug={slug} open={addingRepo} onOpenChange={setAddingRepo} shown={(layout?.sidebar?.repos ?? []).map((r) => r.repo.toLowerCase())} onAdded={() => void revalidate()} />
209
210 {general && featured.length > 0 && (
211 <section className="mt-10 mb-6">
212 <SectionTitle
213 action={
214 <Link to={`/${slug}/-/docs/templates`} className="text-xs text-muted hover:text-fg">
215 All templates
216 </Link>
217 }
218 >
219 Start from a template
220 </SectionTitle>
221 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
222 {featured.map((t) => (
223 <TemplateCard
224 key={t.id}
225 template={t}
226 action={
227 <button
228 type="button"
229 disabled={starting != null}
230 aria-busy={starting === t.id}
231 onClick={() => create(t.id)}
232 className="text-xs font-medium text-accent hover:underline disabled:text-faint disabled:no-underline"
233 >
234 {starting === t.id ? "Starting…" : `Use in ${general.name}`}
235 </button>
236 }
237 />
238 ))}
239 </div>
240 </section>
241 )}
242 </div>
243 );
244}