Skip to content
224 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 const create = async (template?: string) => {
54 if (!general) return;
55 const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: general.id, template_id: template ?? null } });
56 if (made.ok) navigate(made.value.path);
57 else setError(made.error.message);
58 };
59 if (!home) {
60 return (
61 <div className="mx-auto max-w-3xl">
62 <EmptyState title="Docs didn't answer">Try again in a moment.</EmptyState>
63 </div>
64 );
65 }
66 const featured = templates.filter((t) => BUILTIN_TEMPLATE_IDS.includes(t.id)).slice(0, 4);
67 return (
68 <div className="mx-auto max-w-5xl">
69 {/* On a phone, the spaces and pages first: the sidebar as the page. */}
70 <div className="-mx-4 -mt-6 mb-6 border-b border-line md:hidden">
71 <div className="h-[60dvh]">
72 <DocsSidebar slug={slug} />
73 </div>
74 </div>
75 <div className="flex flex-wrap items-end justify-between gap-4">
76 <div>
77 <div className="flex items-center gap-2 text-accent">
78 <BookOpen size={18} />
79 <span className="text-xs font-medium tracking-wide uppercase">Docs</span>
80 </div>
81 <h1 className="mt-2 text-2xl font-semibold tracking-tight">What your workspace knows</h1>
82 <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>
83 </div>
84 <div className="flex items-center gap-2">
85 <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">
86 <FolderGit2 size={15} /> Show a project&apos;s docs
87 </button>
88 <ButtonLink to={`/${slug}/-/docs/new`} variant="quiet">
89 <Plus size={15} /> New space
90 </ButtonLink>
91 {general && (
92 <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">
93 <FilePlus2 size={15} /> New page
94 </button>
95 )}
96 </div>
97 </div>
98 {error && (
99 <div className="mt-3">
100 <ErrorText>{error}</ErrorText>
101 </div>
102 )}
103
104 <div className="mt-6 flex flex-wrap items-center gap-2">
105 <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">
106 <Search size={15} className="text-faint" aria-hidden="true" />
107 <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" />
108 {home.project && <input type="hidden" name="project" value={home.project} />}
109 </Form>
110 {home.projects.length > 0 && (
111 <Form method="get" id="docs-project">
112 <SelectField
113 name="project"
114 aria-label="Filter by project"
115 value={home.project ?? ""}
116 afterChange={() => submit(document.getElementById("docs-project") as HTMLFormElement)}
117 options={[{ value: "", label: "All projects" }, ...home.projects.map((p) => ({ value: p, label: p }))]}
118 className="h-9 min-w-48"
119 />
120 </Form>
121 )}
122 </div>
123
124 {home.stale.length > 0 && (
125 <section className="mt-8">
126 <SectionTitle
127 action={
128 <Link to={`/${slug}/-/docs/stale`} className="text-xs text-muted hover:text-fg">
129 All of them
130 </Link>
131 }
132 >
133 <span className="inline-flex items-center gap-1.5">
134 <AlertTriangle size={13} className="text-warn" /> Possibly out of date
135 </span>
136 </SectionTitle>
137 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
138 {home.stale.map((p) => (
139 <PageCard key={p.id} page={p} space={spacesById.get(p.space_id)} />
140 ))}
141 </div>
142 </section>
143 )}
144
145 <section className="mt-8">
146 <SectionTitle>{home.project ? `Recently edited · ${home.project}` : "Recently edited"}</SectionTitle>
147 {home.recent.length ? (
148 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
149 {home.recent.map((p) => (
150 <PageCard key={p.id} page={p} space={spacesById.get(p.space_id)} />
151 ))}
152 </div>
153 ) : (
154 <EmptyState title={home.project ? "No pages about this project yet" : "No pages yet"}>
155 {home.project ? "Link a space or a page to this project from its settings." : "Start one from a template below, or with New page."}
156 </EmptyState>
157 )}
158 </section>
159
160 {home.mine.length > 0 && (
161 <section className="mt-10">
162 <SectionTitle>Your pages</SectionTitle>
163 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
164 {home.mine.map((p) => (
165 <li key={p.id}>
166 <Link to={p.path} prefetch="intent" className="flex items-center gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-raised">
167 <span className="w-5 text-center">{p.icon ?? "📄"}</span>
168 <span className="min-w-0 grow truncate">{p.title || "Untitled"}</span>
169 <span className="hidden text-xs text-faint sm:inline">{spacesById.get(p.space_id)?.name}</span>
170 </Link>
171 </li>
172 ))}
173 </ul>
174 </section>
175 )}
176
177 <section className="mt-10">
178 <SectionTitle
179 action={
180 <Link to={`/${slug}/-/docs/new`} className="text-xs text-muted hover:text-fg">
181 New space
182 </Link>
183 }
184 >
185 Spaces
186 </SectionTitle>
187 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
188 {home.spaces.map((s) => (
189 <SpaceCard key={s.id} slug={slug} space={s} />
190 ))}
191 </div>
192 </section>
193
194 <RepoDocsDialog slug={slug} open={addingRepo} onOpenChange={setAddingRepo} shown={(layout?.sidebar?.repos ?? []).map((r) => r.repo.toLowerCase())} onAdded={() => void revalidate()} />
195
196 {general && featured.length > 0 && (
197 <section className="mt-10 mb-6">
198 <SectionTitle
199 action={
200 <Link to={`/${slug}/-/docs/templates`} className="text-xs text-muted hover:text-fg">
201 All templates
202 </Link>
203 }
204 >
205 Start from a template
206 </SectionTitle>
207 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
208 {featured.map((t) => (
209 <TemplateCard
210 key={t.id}
211 template={t}
212 action={
213 <button type="button" onClick={() => create(t.id)} className="text-xs font-medium text-accent hover:underline">
214 Use in {general.name}
215 </button>
216 }
217 />
218 ))}
219 </div>
220 </section>
221 )}
222 </div>
223 );
224}