Skip to content
370 linesCodeBlameRaw
1/**
2 * Docs mode's sidebar (beside the rail, docs/WORKSPACE.md "Shell"):
3 * search, Home, Templates and Trash; Favorites and Recent; then each
4 * space with its page tree, which opens to the page being read. Pages are
5 * dragged to reorder them or to put one inside another; the ⋯ menu moves
6 * them too, for keyboards and phones.
7 */
8import type { DocsSidebarSpace } from "@g1t/contracts";
9import { BookOpen, ChevronRight, Clock, FileText, Home, LayoutTemplate, Lock, Plus, Search, Settings, Star, Trash2, Users } from "lucide-react";
10import { type DragEvent, type ReactNode, useEffect, useMemo, useState } from "react";
11import { NavLink, useLocation, useNavigate, useParams } from "react-router";
12
13import { buildTree, canDo, pageIdOf, pagePath, pathTo, type TreeItem } from "../../lib/docs";
14import { Hint } from "../ui/hint";
15import { Skeleton } from "../ui/skeleton";
16import { docsRequest, useDocsAction, useDocsData } from "./actions";
17
18const ROW = "group flex h-8 items-center gap-1.5 rounded-md pr-1 text-[0.8125rem] transition-colors";
19
20function SideLink({ to, end, icon, children, trailing }: { to: string; end?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode }) {
21 return (
22 <NavLink
23 to={to}
24 end={end}
25 prefetch="intent"
26 className={({ isActive }) => `${ROW} pl-2 ${isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`}
27 >
28 <span className="flex w-5 shrink-0 justify-center text-faint">{icon}</span>
29 <span className="min-w-0 grow truncate">{children}</span>
30 {trailing}
31 </NavLink>
32 );
33}
34
35function Section({ title, children, action, open: initially = true }: { title: string; children: ReactNode; action?: ReactNode; open?: boolean }) {
36 const [open, setOpen] = useState(initially);
37 return (
38 <section className="mt-4">
39 <div className="mb-0.5 flex items-center gap-1 pr-1">
40 <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">
41 <ChevronRight size={12} className={`transition-transform ${open ? "rotate-90" : ""}`} />
42 {title}
43 </button>
44 {action}
45 </div>
46 {open && children}
47 </section>
48 );
49}
50
51/** A page's icon: its emoji, or the plain page. */
52export function PageIcon({ icon, size = 15 }: { icon: string | null | undefined; size?: number }) {
53 if (icon) return <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">{icon}</span>;
54 return <FileText size={size} className="text-faint" aria-hidden="true" />;
55}
56
57/** A space's icon: its emoji, or the book. */
58export function SpaceIcon({ space, size = 15 }: { space: Pick<DocsSidebarSpace, "icon" | "kind">; size?: number }) {
59 if (space.icon) return <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">{space.icon}</span>;
60 if (space.kind === "private") return <Lock size={size} className="text-faint" aria-hidden="true" />;
61 if (space.kind === "team") return <Users size={size} className="text-faint" aria-hidden="true" />;
62 return <BookOpen size={size} className="text-faint" aria-hidden="true" />;
63}
64
65type Drag = { id: string; space: string } | null;
66
67function TreeRow({
68 slug,
69 space,
70 item,
71 current,
72 open,
73 toggle,
74 drag,
75 setDrag,
76 onDrop,
77 onAdd,
78}: {
79 slug: string;
80 space: DocsSidebarSpace;
81 item: TreeItem;
82 current: string | null;
83 open: Set<string>;
84 toggle: (id: string) => void;
85 drag: Drag;
86 setDrag: (d: Drag) => void;
87 onDrop: (target: TreeItem, where: "before" | "inside") => void;
88 onAdd: (parent: string) => void;
89}) {
90 const [over, setOver] = useState<"before" | "inside" | null>(null);
91 const expanded = open.has(item.id);
92 const active = current === item.id;
93 const editable = canDo(space.viewer_role, "edit");
94 const zone = (e: DragEvent<HTMLElement>): "before" | "inside" => {
95 const box = e.currentTarget.getBoundingClientRect();
96 return e.clientY - box.top < box.height * 0.35 ? "before" : "inside";
97 };
98 return (
99 <li>
100 <div
101 draggable={editable}
102 onDragStart={(e) => {
103 e.dataTransfer.effectAllowed = "move";
104 e.dataTransfer.setData("text/plain", item.id);
105 setDrag({ id: item.id, space: space.id });
106 }}
107 onDragEnd={() => setDrag(null)}
108 onDragOver={(e) => {
109 if (!drag || drag.id === item.id || !editable) return;
110 e.preventDefault();
111 setOver(zone(e));
112 }}
113 onDragLeave={() => setOver(null)}
114 onDrop={(e) => {
115 e.preventDefault();
116 const where = zone(e);
117 setOver(null);
118 if (drag && drag.id !== item.id) onDrop(item, where);
119 }}
120 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" : ""}`}
121 style={{ paddingLeft: 4 + item.depth * 14 }}
122 >
123 {over === "before" && <span className="pointer-events-none absolute inset-x-1 -top-px h-0.5 rounded bg-accent" aria-hidden="true" />}
124 <button
125 type="button"
126 onClick={() => toggle(item.id)}
127 aria-label={expanded ? "Collapse" : "Expand"}
128 aria-expanded={expanded}
129 className={`flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg ${item.children.length ? "" : "invisible"}`}
130 >
131 <ChevronRight size={13} className={`transition-transform ${expanded ? "rotate-90" : ""}`} />
132 </button>
133 <NavLink to={pagePath(slug, space.slug, item.title, item.id)} prefetch="intent" className="flex min-w-0 grow items-center gap-1.5">
134 <span className="flex w-4 shrink-0 justify-center">
135 <PageIcon icon={item.icon} size={14} />
136 </span>
137 <span className="min-w-0 truncate">{item.title || "Untitled"}</span>
138 </NavLink>
139 {editable && (
140 <Hint label="Add a page inside">
141 <button
142 type="button"
143 onClick={() => onAdd(item.id)}
144 aria-label={`Add a page inside ${item.title || "Untitled"}`}
145 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"
146 >
147 <Plus size={13} />
148 </button>
149 </Hint>
150 )}
151 </div>
152 {expanded && item.children.length > 0 && (
153 <ul>
154 {item.children.map((child) => (
155 <TreeRow key={child.id} slug={slug} space={space} item={child} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={onDrop} onAdd={onAdd} />
156 ))}
157 </ul>
158 )}
159 </li>
160 );
161}
162
163function SpaceTree({ slug, space, current, drag, setDrag }: { slug: string; space: DocsSidebarSpace; current: string | null; drag: Drag; setDrag: (d: Drag) => void }) {
164 const navigate = useNavigate();
165 const { send } = useDocsAction(slug);
166 const tree = useMemo(() => buildTree(space.pages), [space.pages]);
167 const [open, setOpen] = useState<Set<string>>(() => new Set(pathTo(space.pages, current)));
168 const [collapsed, setCollapsed] = useState(false);
169 // Opening a page opens the way to it.
170 useEffect(() => {
171 if (!current) return;
172 const path = pathTo(space.pages, current);
173 if (path.length) setOpen((was) => new Set([...was, ...path]));
174 }, [current, space.pages]);
175 const toggle = (id: string) =>
176 setOpen((was) => {
177 const next = new Set(was);
178 if (next.has(id)) next.delete(id);
179 else next.add(id);
180 return next;
181 });
182 const add = async (parent: string | null) => {
183 const made = await send<{ path: string }>("create_page", { page: { space_id: space.id, parent_id: parent } });
184 if (made.ok) {
185 if (parent) setOpen((was) => new Set([...was, parent]));
186 navigate(made.value.path);
187 }
188 };
189 const drop = async (target: TreeItem, where: "before" | "inside") => {
190 if (!drag) return;
191 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 };
192 setDrag(null);
193 await send("move_page", { page_id: drag.id, move });
194 if (where === "inside") setOpen((was) => new Set([...was, target.id]));
195 };
196 const editable = canDo(space.viewer_role, "edit");
197 return (
198 <li className="mt-1">
199 <div
200 className={`${ROW} text-fg-soft`}
201 onDragOver={(e) => {
202 if (drag && editable) e.preventDefault();
203 }}
204 onDrop={async (e) => {
205 // Dropped on the space itself: to the top of it, at the end.
206 e.preventDefault();
207 if (!drag) return;
208 const id = drag.id;
209 setDrag(null);
210 await send("move_page", { page_id: id, move: { space_id: space.id, parent_id: null, before_id: null } });
211 }}
212 >
213 <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">
214 <ChevronRight size={13} className={`transition-transform ${collapsed ? "" : "rotate-90"}`} />
215 </button>
216 <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" : ""}`}>
217 <span className="flex w-4 shrink-0 justify-center">
218 <SpaceIcon space={space} size={14} />
219 </span>
220 <span className="min-w-0 truncate">{space.name}</span>
221 </NavLink>
222 {canDo(space.viewer_role, "manage") && (
223 <Hint label="Space settings">
224 <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">
225 <Settings size={13} />
226 </NavLink>
227 </Hint>
228 )}
229 {editable && (
230 <Hint label={`New page in ${space.name}`}>
231 <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">
232 <Plus size={13} />
233 </button>
234 </Hint>
235 )}
236 </div>
237 {!collapsed && (
238 <ul>
239 {tree.map((item) => (
240 <TreeRow key={item.id} slug={slug} space={space} item={item} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={drop} onAdd={add} />
241 ))}
242 {tree.length === 0 && <li className="py-1 pr-2 pl-9 text-xs text-faint">No pages yet.</li>}
243 </ul>
244 )}
245 </li>
246 );
247}
248
249export function DocsSidebar({ slug, onClose }: { slug: string; onClose?: () => void }) {
250 const data = useDocsData();
251 const navigate = useNavigate();
252 const params = useParams();
253 const { pathname } = useLocation();
254 const current = pageIdOf(params.page);
255 const [drag, setDrag] = useState<Drag>(null);
256 const [query, setQuery] = useState("");
257 const sidebar = data?.sidebar;
258 const general = sidebar?.spaces.find((s) => s.is_default) ?? sidebar?.spaces.find((s) => canDo(s.viewer_role, "edit"));
259 const newPage = async () => {
260 if (!general) return;
261 const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: general.id } });
262 if (made.ok) navigate(made.value.path);
263 };
264 return (
265 <div className="flex h-full flex-col">
266 <div className="flex h-14 shrink-0 items-center gap-1 border-b border-line pr-2.5 pl-4">
267 <h2 className="min-w-0 grow truncate text-[0.9375rem] font-semibold">Docs</h2>
268 {general && (
269 <Hint label="New page">
270 <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">
271 <Plus size={16} />
272 </button>
273 </Hint>
274 )}
275 {onClose && (
276 <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">
277 <ChevronRight size={16} className="rotate-180" />
278 </button>
279 )}
280 </div>
281 <nav aria-label="Docs" className="min-h-0 grow overflow-y-auto px-2.5 pt-3 pb-4 [scrollbar-width:thin]">
282 <form
283 role="search"
284 onSubmit={(e) => {
285 e.preventDefault();
286 navigate(`/${slug}/-/docs/search?q=${encodeURIComponent(query.trim())}`);
287 }}
288 className="mb-2"
289 >
290 <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">
291 <Search size={14} aria-hidden="true" />
292 <span className="sr-only">Search docs</span>
293 <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" />
294 </label>
295 </form>
296 <div className="space-y-px">
297 <SideLink to={`/${slug}/-/docs`} end icon={<Home size={15} />}>
298 Home
299 </SideLink>
300 <SideLink to={`/${slug}/-/docs/templates`} icon={<LayoutTemplate size={15} />}>
301 Templates
302 </SideLink>
303 <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}>
304 Trash
305 </SideLink>
306 </div>
307 {sidebar === undefined || sidebar === null ? (
308 sidebar === null ? (
309 <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>
310 ) : (
311 <div className="mt-5 space-y-3 px-2" aria-busy="true">
312 {[0, 1, 2, 3].map((i) => (
313 <Skeleton key={i} className="h-3 w-36" />
314 ))}
315 </div>
316 )
317 ) : (
318 <>
319 {sidebar.favorites.length > 0 && (
320 <Section title="Favorites">
321 <ul className="space-y-px">
322 {sidebar.favorites.map((p) => (
323 <li key={p.id}>
324 <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" />}>
325 {p.title || "Untitled"}
326 </SideLink>
327 </li>
328 ))}
329 </ul>
330 </Section>
331 )}
332 {sidebar.recent.length > 0 && (
333 <Section title="Recent" open={false}>
334 <ul className="space-y-px">
335 {sidebar.recent.slice(0, 6).map((p) => (
336 <li key={p.id}>
337 <SideLink to={p.path} icon={<Clock size={14} />}>
338 {p.title || "Untitled"}
339 </SideLink>
340 </li>
341 ))}
342 </ul>
343 </Section>
344 )}
345 <Section
346 title="Spaces"
347 action={
348 sidebar.can_create_space ? (
349 <Hint label="New space">
350 <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">
351 <Plus size={13} />
352 </NavLink>
353 </Hint>
354 ) : undefined
355 }
356 >
357 <ul>
358 {sidebar.spaces.map((space) => (
359 <SpaceTree key={space.id} slug={slug} space={space} current={current} drag={drag} setDrag={setDrag} />
360 ))}
361 </ul>
362 </Section>
363 {pathname === `/${slug}/-/docs` && sidebar.spaces.length === 0 && <p className="mt-3 px-2 text-xs text-faint">No spaces you can read yet.</p>}
364 </>
365 )}
366 </nav>
367 </div>
368 );
369}
370