Skip to content
489 linesCodeBlameRaw
1/**
2 * Docs mode's sidebar (beside the rail, docs/WORKSPACE.md "Shell"):
3 * search, Home, Possibly stale, Templates and Trash; Favorites and
4 * Recent; each space with its page tree, which opens to the page being
5 * read; then projects' docs folders, read-only. Pages are
6 * dragged to reorder them or to put one inside another; the ⋯ menu moves
7 * them too, for keyboards and phones.
8 */
9import type { DocRepoSpace, DocsSidebarSpace } from "@g1t/contracts";
10import { AlertTriangle, BookOpen, ChevronRight, Clock, FileText, Folder, FolderGit2, Home, LayoutTemplate, Lock, Plus, Search, Settings, Star, Trash2, Users } from "lucide-react";
11import { type DragEvent, type ReactNode, useEffect, useMemo, useState } from "react";
12import { NavLink, useLocation, useNavigate, useParams, useRevalidator } from "react-router";
13
14import { buildTree, canDo, pageIdOf, pagePath, pathTo, repoFilePath, repoFolders, type RepoFolder, type TreeItem } from "../../lib/docs";
15import { Hint } from "../ui/hint";
16import { Skeleton } from "../ui/skeleton";
17import { docsRequest, useDocsAction, useDocsData } from "./actions";
18import { RepoDocsDialog } from "./code";
19
20const ROW = "group flex h-8 items-center gap-1.5 rounded-md pr-1 text-[0.8125rem] transition-colors";
21
22function SideLink({ to, end, icon, children, trailing }: { to: string; end?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode }) {
23 return (
24 <NavLink
25 to={to}
26 end={end}
27 prefetch="intent"
28 className={({ isActive }) => `${ROW} pl-2 ${isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`}
29 >
30 <span className="flex w-5 shrink-0 justify-center text-faint">{icon}</span>
31 <span className="min-w-0 grow truncate">{children}</span>
32 {trailing}
33 </NavLink>
34 );
35}
36
37function Section({ title, children, action, open: initially = true }: { title: string; children: ReactNode; action?: ReactNode; open?: boolean }) {
38 const [open, setOpen] = useState(initially);
39 return (
40 <section className="mt-4">
41 <div className="mb-0.5 flex items-center gap-1 pr-1">
42 <button type="button" onClick={() => setOpen(!open)} aria-expanded={open} className="flex grow items-center gap-1 rounded px-1 text-xs font-medium text-faint transition-colors hover:text-muted">
43 <ChevronRight size={12} className={`transition-transform ${open ? "rotate-90" : ""}`} />
44 {title}
45 </button>
46 {action}
47 </div>
48 {open && children}
49 </section>
50 );
51}
52
53/** A page's icon: its emoji, or the plain page. */
54export function PageIcon({ icon, size = 15 }: { icon: string | null | undefined; size?: number }) {
55 if (icon) return <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">{icon}</span>;
56 return <FileText size={size} className="text-faint" aria-hidden="true" />;
57}
58
59/** A space's icon: its emoji, or the book. */
60export function SpaceIcon({ space, size = 15 }: { space: Pick<DocsSidebarSpace, "icon" | "kind">; size?: number }) {
61 if (space.icon) return <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">{space.icon}</span>;
62 if (space.kind === "private") return <Lock size={size} className="text-faint" aria-hidden="true" />;
63 if (space.kind === "team") return <Users size={size} className="text-faint" aria-hidden="true" />;
64 return <BookOpen size={size} className="text-faint" aria-hidden="true" />;
65}
66
67type Drag = { id: string; space: string } | null;
68
69function TreeRow({
70 slug,
71 space,
72 item,
73 current,
74 open,
75 toggle,
76 drag,
77 setDrag,
78 onDrop,
79 onAdd,
80 stale,
81}: {
82 slug: string;
83 space: DocsSidebarSpace;
84 stale: Set<string>;
85 item: TreeItem;
86 current: string | null;
87 open: Set<string>;
88 toggle: (id: string) => void;
89 drag: Drag;
90 setDrag: (d: Drag) => void;
91 onDrop: (target: TreeItem, where: "before" | "inside") => void;
92 onAdd: (parent: string) => void;
93}) {
94 const [over, setOver] = useState<"before" | "inside" | null>(null);
95 const expanded = open.has(item.id);
96 const active = current === item.id;
97 const editable = canDo(space.viewer_role, "edit");
98 const zone = (e: DragEvent<HTMLElement>): "before" | "inside" => {
99 const box = e.currentTarget.getBoundingClientRect();
100 return e.clientY - box.top < box.height * 0.35 ? "before" : "inside";
101 };
102 return (
103 <li>
104 <div
105 draggable={editable}
106 onDragStart={(e) => {
107 e.dataTransfer.effectAllowed = "move";
108 e.dataTransfer.setData("text/plain", item.id);
109 setDrag({ id: item.id, space: space.id });
110 }}
111 onDragEnd={() => setDrag(null)}
112 onDragOver={(e) => {
113 if (!drag || drag.id === item.id || !editable) return;
114 e.preventDefault();
115 setOver(zone(e));
116 }}
117 onDragLeave={() => setOver(null)}
118 onDrop={(e) => {
119 e.preventDefault();
120 const where = zone(e);
121 setOver(null);
122 if (drag && drag.id !== item.id) onDrop(item, where);
123 }}
124 className={`${ROW} relative ${active ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"} ${over === "inside" ? "ring-1 ring-accent/60 ring-inset" : ""}`}
125 style={{ paddingLeft: 4 + item.depth * 14 }}
126 >
127 {over === "before" && <span className="pointer-events-none absolute inset-x-1 -top-px h-0.5 rounded bg-accent" aria-hidden="true" />}
128 <button
129 type="button"
130 onClick={() => toggle(item.id)}
131 aria-label={expanded ? "Collapse" : "Expand"}
132 aria-expanded={expanded}
133 className={`flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg ${item.children.length ? "" : "invisible"}`}
134 >
135 <ChevronRight size={13} className={`transition-transform ${expanded ? "rotate-90" : ""}`} />
136 </button>
137 <NavLink to={pagePath(slug, space.slug, item.title, item.id)} prefetch="intent" className="flex min-w-0 grow items-center gap-1.5">
138 <span className="flex w-4 shrink-0 justify-center">
139 <PageIcon icon={item.icon} size={14} />
140 </span>
141 <span className="min-w-0 truncate">{item.title || "Untitled"}</span>
142 {stale.has(item.id) && (
143 <Hint label="Possibly out of date: code it cites changed">
144 <span className="ml-auto size-1.5 shrink-0 rounded-full bg-warn" aria-label="Possibly out of date" />
145 </Hint>
146 )}
147 </NavLink>
148 {editable && (
149 <Hint label="Add a page inside">
150 <button
151 type="button"
152 onClick={() => onAdd(item.id)}
153 aria-label={`Add a page inside ${item.title || "Untitled"}`}
154 className="flex size-6 shrink-0 items-center justify-center rounded text-faint opacity-0 group-hover:opacity-100 hover:bg-line hover:text-fg focus-visible:opacity-100 pointer-coarse:size-9 pointer-coarse:opacity-100"
155 >
156 <Plus size={13} />
157 </button>
158 </Hint>
159 )}
160 </div>
161 {expanded && item.children.length > 0 && (
162 <ul>
163 {item.children.map((child) => (
164 <TreeRow key={child.id} slug={slug} space={space} stale={stale} item={child} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={onDrop} onAdd={onAdd} />
165 ))}
166 </ul>
167 )}
168 </li>
169 );
170}
171
172function SpaceTree({ slug, space, current, drag, setDrag }: { slug: string; space: DocsSidebarSpace; current: string | null; drag: Drag; setDrag: (d: Drag) => void }) {
173 const navigate = useNavigate();
174 const { send } = useDocsAction(slug);
175 const tree = useMemo(() => buildTree(space.pages), [space.pages]);
176 const stale = useMemo(() => new Set(space.pages.filter((p) => p.stale).map((p) => p.id)), [space.pages]);
177 const [open, setOpen] = useState<Set<string>>(() => new Set(pathTo(space.pages, current)));
178 const [collapsed, setCollapsed] = useState(false);
179 // Opening a page opens the way to it.
180 useEffect(() => {
181 if (!current) return;
182 const path = pathTo(space.pages, current);
183 if (path.length) setOpen((was) => new Set([...was, ...path]));
184 }, [current, space.pages]);
185 const toggle = (id: string) =>
186 setOpen((was) => {
187 const next = new Set(was);
188 if (next.has(id)) next.delete(id);
189 else next.add(id);
190 return next;
191 });
192 const add = async (parent: string | null) => {
193 const made = await send<{ path: string }>("create_page", { page: { space_id: space.id, parent_id: parent } });
194 if (made.ok) {
195 if (parent) setOpen((was) => new Set([...was, parent]));
196 navigate(made.value.path);
197 }
198 };
199 const drop = async (target: TreeItem, where: "before" | "inside") => {
200 if (!drag) return;
201 const move = where === "inside" ? { space_id: space.id, parent_id: target.id, before_id: null } : { space_id: space.id, parent_id: target.parent_id, before_id: target.id };
202 setDrag(null);
203 await send("move_page", { page_id: drag.id, move });
204 if (where === "inside") setOpen((was) => new Set([...was, target.id]));
205 };
206 const editable = canDo(space.viewer_role, "edit");
207 return (
208 <li className="mt-1">
209 <div
210 className={`${ROW} text-fg-soft`}
211 onDragOver={(e) => {
212 if (drag && editable) e.preventDefault();
213 }}
214 onDrop={async (e) => {
215 // Dropped on the space itself: to the top of it, at the end.
216 e.preventDefault();
217 if (!drag) return;
218 const id = drag.id;
219 setDrag(null);
220 await send("move_page", { page_id: id, move: { space_id: space.id, parent_id: null, before_id: null } });
221 }}
222 >
223 <button type="button" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed} aria-label={collapsed ? `Show ${space.name}` : `Hide ${space.name}`} className="flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg">
224 <ChevronRight size={13} className={`transition-transform ${collapsed ? "" : "rotate-90"}`} />
225 </button>
226 <NavLink to={`/${slug}/-/docs/${space.slug}`} end prefetch="intent" className={({ isActive }) => `flex min-w-0 grow items-center gap-1.5 font-medium ${isActive ? "text-fg" : ""}`}>
227 <span className="flex w-4 shrink-0 justify-center">
228 <SpaceIcon space={space} size={14} />
229 </span>
230 <span className="min-w-0 truncate">{space.name}</span>
231 </NavLink>
232 {canDo(space.viewer_role, "manage") && (
233 <Hint label="Space settings">
234 <NavLink to={`/${slug}/-/docs/${space.slug}/settings`} aria-label={`${space.name} settings`} className="flex size-6 shrink-0 items-center justify-center rounded text-faint opacity-0 group-hover:opacity-100 hover:bg-line hover:text-fg focus-visible:opacity-100 pointer-coarse:size-9 pointer-coarse:opacity-100">
235 <Settings size={13} />
236 </NavLink>
237 </Hint>
238 )}
239 {editable && (
240 <Hint label={`New page in ${space.name}`}>
241 <button type="button" onClick={() => add(null)} aria-label={`New page in ${space.name}`} className="flex size-6 shrink-0 items-center justify-center rounded text-faint opacity-0 group-hover:opacity-100 hover:bg-line hover:text-fg focus-visible:opacity-100 pointer-coarse:size-9 pointer-coarse:opacity-100">
242 <Plus size={13} />
243 </button>
244 </Hint>
245 )}
246 </div>
247 {!collapsed && (
248 <ul>
249 {tree.map((item) => (
250 <TreeRow key={item.id} slug={slug} space={space} stale={stale} item={item} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={drop} onAdd={add} />
251 ))}
252 {tree.length === 0 && <li className="py-1 pr-2 pl-9 text-xs text-faint">No pages yet.</li>}
253 </ul>
254 )}
255 </li>
256 );
257}
258
259function RepoFolderRows({ slug, repo, folder, depth, current }: { slug: string; repo: string; folder: RepoFolder; depth: number; current: string }) {
260 const [open, setOpen] = useState<Set<string>>(() => new Set(folder.folders.filter((f) => current.includes(`/${f.path}/`)).map((f) => f.path)));
261 return (
262 <>
263 {folder.files.map((f) => {
264 const to = repoFilePath(slug, repo, f.path);
265 return (
266 <li key={f.path}>
267 <NavLink to={to} prefetch="intent" className={({ isActive }) => `${ROW} ${isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`} style={{ paddingLeft: 26 + depth * 14 }}>
268 <FileText size={14} className="shrink-0 text-faint" aria-hidden="true" />
269 <span className="min-w-0 truncate">{f.title}</span>
270 </NavLink>
271 </li>
272 );
273 })}
274 {folder.folders.map((sub) => {
275 const expanded = open.has(sub.path);
276 return (
277 <li key={sub.path}>
278 <button
279 type="button"
280 aria-expanded={expanded}
281 onClick={() =>
282 setOpen((was) => {
283 const next = new Set(was);
284 if (next.has(sub.path)) next.delete(sub.path);
285 else next.add(sub.path);
286 return next;
287 })
288 }
289 className={`${ROW} w-full text-muted hover:bg-raised/60 hover:text-fg`}
290 style={{ paddingLeft: 4 + (depth + 1) * 14 }}
291 >
292 <ChevronRight size={13} className={`shrink-0 text-faint transition-transform ${expanded ? "rotate-90" : ""}`} />
293 <Folder size={14} className="shrink-0 text-faint" aria-hidden="true" />
294 <span className="min-w-0 truncate">{sub.name}</span>
295 </button>
296 {expanded && (
297 <ul>
298 <RepoFolderRows slug={slug} repo={repo} folder={sub} depth={depth + 1} current={current} />
299 </ul>
300 )}
301 </li>
302 );
303 })}
304 </>
305 );
306}
307
308/** A project's docs folder: read-only, its files as folders, from the repository's default branch. */
309function RepoTree({ slug, space }: { slug: string; space: DocRepoSpace }) {
310 const { pathname } = useLocation();
311 const base = `/${slug}/-/docs/repo/${space.repo}/`;
312 const here = decodeURIComponent(pathname).startsWith(base);
313 const [collapsed, setCollapsed] = useState(!here);
314 const root = useMemo(() => repoFolders(space.files), [space.files]);
315 return (
316 <li className="mt-1">
317 <div className={`${ROW} text-fg-soft`}>
318 <button type="button" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed} aria-label={collapsed ? `Show ${space.repo}'s docs` : `Hide ${space.repo}'s docs`} className="flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg">
319 <ChevronRight size={13} className={`transition-transform ${collapsed ? "" : "rotate-90"}`} />
320 </button>
321 <span className="flex w-4 shrink-0 justify-center">
322 <FolderGit2 size={14} className="text-faint" aria-hidden="true" />
323 </span>
324 <span className="min-w-0 grow truncate font-medium">{space.repo}</span>
325 </div>
326 {!collapsed && (
327 <ul>
328 <RepoFolderRows slug={slug} repo={space.repo} folder={root} depth={0} current={decodeURIComponent(pathname)} />
329 {space.files.length === 0 && <li className="py-1 pr-2 pl-9 text-xs text-faint">{space.indexed_at ? "No docs folder or README." : "Reading its docs…"}</li>}
330 </ul>
331 )}
332 </li>
333 );
334}
335
336export function DocsSidebar({ slug, onClose }: { slug: string; onClose?: () => void }) {
337 const data = useDocsData();
338 const navigate = useNavigate();
339 const params = useParams();
340 const { pathname } = useLocation();
341 const current = pageIdOf(params.page);
342 const [drag, setDrag] = useState<Drag>(null);
343 const [query, setQuery] = useState("");
344 const [addingRepo, setAddingRepo] = useState(false);
345 const { revalidate } = useRevalidator();
346 const sidebar = data?.sidebar;
347 const general = sidebar?.spaces.find((s) => s.is_default) ?? sidebar?.spaces.find((s) => canDo(s.viewer_role, "edit"));
348 const newPage = async () => {
349 if (!general) return;
350 const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: general.id } });
351 if (made.ok) navigate(made.value.path);
352 };
353 return (
354 <div className="flex h-full flex-col">
355 <div className="flex h-14 shrink-0 items-center gap-1 border-b border-line pr-2.5 pl-4">
356 <h2 className="min-w-0 grow truncate text-[0.9375rem] font-semibold">Docs</h2>
357 {general && (
358 <Hint label="New page">
359 <button type="button" onClick={newPage} aria-label="New page" className="flex size-8 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg">
360 <Plus size={16} />
361 </button>
362 </Hint>
363 )}
364 {onClose && (
365 <button type="button" aria-label="Close menu" onClick={onClose} className="flex size-8 shrink-0 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg">
366 <ChevronRight size={16} className="rotate-180" />
367 </button>
368 )}
369 </div>
370 <nav aria-label="Docs" className="min-h-0 grow overflow-y-auto px-2.5 pt-3 pb-4 [scrollbar-width:thin]">
371 <form
372 role="search"
373 onSubmit={(e) => {
374 e.preventDefault();
375 navigate(`/${slug}/-/docs/search?q=${encodeURIComponent(query.trim())}`);
376 }}
377 className="mb-2"
378 >
379 <label className="flex h-8 items-center gap-2 rounded-md border border-line bg-bg px-2 text-[0.8125rem] text-faint focus-within:border-accent/60">
380 <Search size={14} aria-hidden="true" />
381 <span className="sr-only">Search docs</span>
382 <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search docs" className="min-w-0 grow bg-transparent text-fg outline-none placeholder:text-faint" />
383 </label>
384 </form>
385 <div className="space-y-px">
386 <SideLink to={`/${slug}/-/docs`} end icon={<Home size={15} />}>
387 Home
388 </SideLink>
389 {!!sidebar?.stale_count && (
390 <SideLink to={`/${slug}/-/docs/stale`} icon={<AlertTriangle size={15} className="text-warn" />} trailing={<span className="text-[0.6875rem] text-warn tabular-nums">{sidebar.stale_count}</span>}>
391 Possibly stale
392 </SideLink>
393 )}
394 <SideLink to={`/${slug}/-/docs/templates`} icon={<LayoutTemplate size={15} />}>
395 Templates
396 </SideLink>
397 <SideLink to={`/${slug}/-/docs/trash`} icon={<Trash2 size={15} />} trailing={sidebar?.trash_count ? <span className="text-[0.6875rem] text-faint tabular-nums">{sidebar.trash_count}</span> : undefined}>
398 Trash
399 </SideLink>
400 </div>
401 {sidebar === undefined || sidebar === null ? (
402 sidebar === null ? (
403 <p className="mt-4 px-2 text-xs leading-relaxed text-faint">Docs didn&apos;t answer. Your spaces will show here once it does.</p>
404 ) : (
405 <div className="mt-5 space-y-3 px-2" aria-busy="true">
406 {[0, 1, 2, 3].map((i) => (
407 <Skeleton key={i} className="h-3 w-36" />
408 ))}
409 </div>
410 )
411 ) : (
412 <>
413 {sidebar.favorites.length > 0 && (
414 <Section title="Favorites">
415 <ul className="space-y-px">
416 {sidebar.favorites.map((p) => (
417 <li key={p.id}>
418 <SideLink to={p.path} icon={<PageIcon icon={p.icon} size={14} />} trailing={<Star size={12} className="shrink-0 fill-current text-warn/80" aria-hidden="true" />}>
419 {p.title || "Untitled"}
420 </SideLink>
421 </li>
422 ))}
423 </ul>
424 </Section>
425 )}
426 {sidebar.recent.length > 0 && (
427 <Section title="Recent" open={false}>
428 <ul className="space-y-px">
429 {sidebar.recent.slice(0, 6).map((p) => (
430 <li key={p.id}>
431 <SideLink to={p.path} icon={<Clock size={14} />}>
432 {p.title || "Untitled"}
433 </SideLink>
434 </li>
435 ))}
436 </ul>
437 </Section>
438 )}
439 <Section
440 title="Spaces"
441 action={
442 sidebar.can_create_space ? (
443 <Hint label="New space">
444 <NavLink to={`/${slug}/-/docs/new`} aria-label="New space" className="flex size-6 items-center justify-center rounded text-faint hover:bg-raised hover:text-fg">
445 <Plus size={13} />
446 </NavLink>
447 </Hint>
448 ) : undefined
449 }
450 >
451 <ul>
452 {sidebar.spaces.map((space) => (
453 <SpaceTree key={space.id} slug={slug} space={space} current={current} drag={drag} setDrag={setDrag} />
454 ))}
455 </ul>
456 </Section>
457 {pathname === `/${slug}/-/docs` && sidebar.spaces.length === 0 && <p className="mt-3 px-2 text-xs text-faint">No spaces you can read yet.</p>}
458 <Section
459 title="Projects' docs"
460 open={(sidebar.repos ?? []).length > 0}
461 action={
462 <Hint label="Show a project's docs">
463 <button type="button" onClick={() => setAddingRepo(true)} aria-label="Show a project's docs" className="flex size-6 items-center justify-center rounded text-faint hover:bg-raised hover:text-fg">
464 <Plus size={13} />
465 </button>
466 </Hint>
467 }
468 >
469 <ul>
470 {(sidebar.repos ?? []).map((space) => (
471 <RepoTree key={space.id} slug={slug} space={space} />
472 ))}
473 {(sidebar.repos ?? []).length === 0 && (
474 <li>
475 <button type="button" onClick={() => setAddingRepo(true)} className="px-2 py-1 text-left text-xs text-faint hover:text-fg">
476 Show a repository&apos;s docs folder here, read-only.
477 </button>
478 </li>
479 )}
480 </ul>
481 </Section>
482 <RepoDocsDialog slug={slug} open={addingRepo} onOpenChange={setAddingRepo} shown={(sidebar.repos ?? []).map((r) => r.repo.toLowerCase())} onAdded={() => void revalidate()} />
483 </>
484 )}
485 </nav>
486 </div>
487 );
488}
489