| 1 | import { useSidebarLocation } from "../../lib/use-sidebar-location"; |
| 2 | /** |
| 3 | * Artifacts mode's sidebar (beside the rail; docs.g1t.sh/guides/artifacts/, |
| 4 | * "The sidebar"): search, Home, New and Templates; Favorites; the spaces |
| 5 | * shown (joined open spaces, team spaces, members-only spaces) each with |
| 6 | * its tree; Private, everything of yours in no space; Shared, the tops of |
| 7 | * what others shared with you; then what's possibly out of date, |
| 8 | * projects' docs folders (read-only) and the trash. Rows are dragged to |
| 9 | * reorder them or to put one inside a doc. |
| 10 | */ |
| 11 | import { |
| 12 | FOLIO_KINDS, |
| 13 | folioIdFrom, |
| 14 | folioSlug, |
| 15 | type DocRepoSpace, |
| 16 | type FolioKind, |
| 17 | type FolioRef, |
| 18 | type FolioTreeNode, |
| 19 | type FoliosSidebarSpace, |
| 20 | } from "@g1t/contracts"; |
| 21 | import { |
| 22 | AlertTriangle, |
| 23 | ChevronDown, |
| 24 | ChevronRight, |
| 25 | Compass, |
| 26 | FileText, |
| 27 | Folder, |
| 28 | FolderGit2, |
| 29 | Home, |
| 30 | LayoutTemplate, |
| 31 | Lock, |
| 32 | Plus, |
| 33 | Search, |
| 34 | Settings, |
| 35 | Star, |
| 36 | Trash2, |
| 37 | X, |
| 38 | } from "lucide-react"; |
| 39 | import { type DragEvent, type ReactNode, useEffect, useMemo, useState } from "react"; |
| 40 | import { NavLink, useNavigate, useParams, useRevalidator } from "react-router"; |
| 41 | |
| 42 | import { buildTree, canDo, pathTo, repoFilePath, repoFolders, spacePath, type RepoFolder, type TreeItem } from "../../lib/folios"; |
| 43 | import { Button } from "../ui/button"; |
| 44 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu"; |
| 45 | import { Hint } from "../ui/hint"; |
| 46 | import { Skeleton } from "../ui/skeleton"; |
| 47 | import { foliosRequest, useFoliosAction, useFoliosData } from "./actions"; |
| 48 | import { RepoDocsDialog } from "./doc/code"; |
| 49 | import { FOLIO_KIND_UI, FolioGlyph, KindIcon } from "./kinds"; |
| 50 | import { warmOnIntent } from "./list"; |
| 51 | import { SpaceIcon } from "./parts"; |
| 52 | |
| 53 | const ROW = "group flex h-9 items-center gap-1.5 rounded-md pr-1 text-[0.8125rem] transition-colors"; |
| 54 | const ROW_BUTTON = |
| 55 | "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"; |
| 56 | |
| 57 | function SideLink({ to, end, icon, children, trailing }: { to: string; end?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode }) { |
| 58 | return ( |
| 59 | <NavLink |
| 60 | to={to} |
| 61 | end={end} |
| 62 | prefetch="intent" |
| 63 | className={({ isActive }) => `${ROW} pl-2 ${isActive ? "bg-sidebar-accent font-medium text-fg" : "text-muted hover:bg-sidebar-accent/60 hover:text-fg"}`} |
| 64 | > |
| 65 | <span className="flex w-5 shrink-0 justify-center text-faint">{icon}</span> |
| 66 | <span className="min-w-0 grow truncate">{children}</span> |
| 67 | {trailing} |
| 68 | </NavLink> |
| 69 | ); |
| 70 | } |
| 71 | |
| 72 | function Section({ title, children, action, open: initially = true }: { title: string; children: ReactNode; action?: ReactNode; open?: boolean }) { |
| 73 | const [open, setOpen] = useState(initially); |
| 74 | return ( |
| 75 | <section className="mt-4"> |
| 76 | <div className="mb-0.5 flex items-center gap-1 pr-1"> |
| 77 | <button |
| 78 | type="button" |
| 79 | onClick={() => setOpen(!open)} |
| 80 | aria-expanded={open} |
| 81 | className="flex grow items-center gap-1 rounded px-1 text-[0.6875rem] font-medium tracking-wide text-faint uppercase transition-colors hover:text-muted" |
| 82 | > |
| 83 | <ChevronRight size={12} className={`transition-transform ${open ? "rotate-90" : ""}`} /> |
| 84 | {title} |
| 85 | </button> |
| 86 | {action} |
| 87 | </div> |
| 88 | {open && children} |
| 89 | </section> |
| 90 | ); |
| 91 | } |
| 92 | |
| 93 | /** "New ▾": the four kinds, the ones not here yet shown as coming. */ |
| 94 | export function NewMenu({ onMake, children, align = "end" }: { onMake: (kind: FolioKind) => void; children: ReactNode; align?: "start" | "end" }) { |
| 95 | return ( |
| 96 | <DropdownMenu> |
| 97 | <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> |
| 98 | <DropdownMenuContent align={align} className="w-52"> |
| 99 | {FOLIO_KINDS.map((kind) => { |
| 100 | const ui = FOLIO_KIND_UI[kind]; |
| 101 | return ( |
| 102 | <DropdownMenuItem key={kind} disabled={!ui.ready} onSelect={() => onMake(kind)}> |
| 103 | <KindIcon kind={kind} size={13} box={22} /> |
| 104 | <span className="grow">{ui.label}</span> |
| 105 | {!ui.ready && <span className="text-[0.625rem] text-faint">Coming soon</span>} |
| 106 | </DropdownMenuItem> |
| 107 | ); |
| 108 | })} |
| 109 | </DropdownMenuContent> |
| 110 | </DropdownMenu> |
| 111 | ); |
| 112 | } |
| 113 | |
| 114 | type Drag = { id: string; place: string } | null; |
| 115 | |
| 116 | /** Where a tree lives: a space (by id) or the viewer's Private (null). */ |
| 117 | type Place = { space_id: string | null; key: string; editable: boolean }; |
| 118 | |
| 119 | function TreeRow({ |
| 120 | slug, |
| 121 | place, |
| 122 | item, |
| 123 | current, |
| 124 | open, |
| 125 | toggle, |
| 126 | drag, |
| 127 | setDrag, |
| 128 | onDrop, |
| 129 | onAdd, |
| 130 | }: { |
| 131 | slug: string; |
| 132 | place: Place; |
| 133 | item: TreeItem; |
| 134 | current: string | null; |
| 135 | open: Set<string>; |
| 136 | toggle: (id: string) => void; |
| 137 | drag: Drag; |
| 138 | setDrag: (d: Drag) => void; |
| 139 | onDrop: (target: TreeItem, where: "before" | "inside") => void; |
| 140 | onAdd: (parent: string) => void; |
| 141 | }) { |
| 142 | const [over, setOver] = useState<"before" | "inside" | null>(null); |
| 143 | const expanded = open.has(item.id); |
| 144 | const active = current === item.id; |
| 145 | const holds = item.kind === "doc"; |
| 146 | const zone = (e: DragEvent<HTMLElement>): "before" | "inside" => { |
| 147 | if (!holds) return "before"; |
| 148 | const box = e.currentTarget.getBoundingClientRect(); |
| 149 | return e.clientY - box.top < box.height * 0.35 ? "before" : "inside"; |
| 150 | }; |
| 151 | return ( |
| 152 | <li> |
| 153 | <div |
| 154 | draggable={place.editable} |
| 155 | onDragStart={(e) => { |
| 156 | e.dataTransfer.effectAllowed = "move"; |
| 157 | e.dataTransfer.setData("text/plain", item.id); |
| 158 | setDrag({ id: item.id, place: place.key }); |
| 159 | }} |
| 160 | onDragEnd={() => setDrag(null)} |
| 161 | onDragOver={(e) => { |
| 162 | if (!drag || drag.id === item.id || !place.editable) return; |
| 163 | e.preventDefault(); |
| 164 | setOver(zone(e)); |
| 165 | }} |
| 166 | onDragLeave={() => setOver(null)} |
| 167 | onDrop={(e) => { |
| 168 | e.preventDefault(); |
| 169 | const where = zone(e); |
| 170 | setOver(null); |
| 171 | if (drag && drag.id !== item.id) onDrop(item, where); |
| 172 | }} |
| 173 | className={`${ROW} relative ${active ? "bg-sidebar-accent font-medium text-fg" : "text-muted hover:bg-sidebar-accent/60 hover:text-fg"} ${over === "inside" ? "ring-1 ring-accent/60 ring-inset" : ""}`} |
| 174 | style={{ paddingLeft: 4 + item.depth * 14 }} |
| 175 | > |
| 176 | {over === "before" && <span className="pointer-events-none absolute inset-x-1 -top-px h-0.5 rounded bg-accent" aria-hidden="true" />} |
| 177 | <button |
| 178 | type="button" |
| 179 | onClick={() => toggle(item.id)} |
| 180 | aria-label={expanded ? "Collapse" : "Expand"} |
| 181 | aria-expanded={expanded} |
| 182 | className={`flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg ${item.children.length ? "" : "invisible"}`} |
| 183 | > |
| 184 | <ChevronRight size={13} className={`transition-transform ${expanded ? "rotate-90" : ""}`} /> |
| 185 | </button> |
| 186 | <NavLink |
| 187 | to={`/${slug}/-/artifacts/${folioSlug(item.title, item.id)}`} |
| 188 | prefetch="intent" |
| 189 | {...warmOnIntent(item)} |
| 190 | className="flex min-w-0 grow items-center gap-1.5" |
| 191 | > |
| 192 | <span className="flex w-4 shrink-0 justify-center"> |
| 193 | <FolioGlyph folio={item} size={14} /> |
| 194 | </span> |
| 195 | <span className="min-w-0 truncate">{item.title || "Untitled"}</span> |
| 196 | {item.restricted && ( |
| 197 | <Hint label="Only people invited"> |
| 198 | <Lock size={11} className="shrink-0 text-faint" aria-label="Only people invited" /> |
| 199 | </Hint> |
| 200 | )} |
| 201 | {item.stale && ( |
| 202 | <Hint label="Possibly out of date: code it cites changed"> |
| 203 | <span className="ml-auto size-1.5 shrink-0 rounded-full bg-warn" aria-label="Possibly out of date" /> |
| 204 | </Hint> |
| 205 | )} |
| 206 | </NavLink> |
| 207 | {place.editable && holds && ( |
| 208 | <Hint label="Add a doc inside"> |
| 209 | <button type="button" onClick={() => onAdd(item.id)} aria-label={`Add a doc inside ${item.title || "Untitled"}`} className={ROW_BUTTON}> |
| 210 | <Plus size={13} /> |
| 211 | </button> |
| 212 | </Hint> |
| 213 | )} |
| 214 | </div> |
| 215 | {expanded && item.children.length > 0 && ( |
| 216 | <ul> |
| 217 | {item.children.map((child) => ( |
| 218 | <TreeRow |
| 219 | key={child.id} |
| 220 | slug={slug} |
| 221 | place={place} |
| 222 | item={child} |
| 223 | current={current} |
| 224 | open={open} |
| 225 | toggle={toggle} |
| 226 | drag={drag} |
| 227 | setDrag={setDrag} |
| 228 | onDrop={onDrop} |
| 229 | onAdd={onAdd} |
| 230 | /> |
| 231 | ))} |
| 232 | </ul> |
| 233 | )} |
| 234 | </li> |
| 235 | ); |
| 236 | } |
| 237 | |
| 238 | /** A tree of folios in one place, opened to the one being read; drag to reorder or nest. */ |
| 239 | function Tree({ |
| 240 | slug, |
| 241 | place, |
| 242 | nodes, |
| 243 | current, |
| 244 | drag, |
| 245 | setDrag, |
| 246 | empty, |
| 247 | }: { |
| 248 | slug: string; |
| 249 | place: Place; |
| 250 | nodes: FolioTreeNode[]; |
| 251 | current: string | null; |
| 252 | drag: Drag; |
| 253 | setDrag: (d: Drag) => void; |
| 254 | empty: string; |
| 255 | }) { |
| 256 | const navigate = useNavigate(); |
| 257 | const { send } = useFoliosAction(slug); |
| 258 | const tree = useMemo(() => buildTree(nodes), [nodes]); |
| 259 | const [open, setOpen] = useState<Set<string>>(() => new Set(pathTo(nodes, current))); |
| 260 | // Opening an artifact opens the way to it. |
| 261 | useEffect(() => { |
| 262 | if (!current) return; |
| 263 | const path = pathTo(nodes, current); |
| 264 | if (path.length) setOpen((was) => new Set([...was, ...path])); |
| 265 | }, [current, nodes]); |
| 266 | const toggle = (id: string) => |
| 267 | setOpen((was) => { |
| 268 | const next = new Set(was); |
| 269 | if (next.has(id)) next.delete(id); |
| 270 | else next.add(id); |
| 271 | return next; |
| 272 | }); |
| 273 | const add = async (parent: string) => { |
| 274 | const made = await send<{ path: string }>("create", { folio: { kind: "doc", parent_id: parent } }); |
| 275 | if (made.ok) { |
| 276 | setOpen((was) => new Set([...was, parent])); |
| 277 | navigate(made.value.path); |
| 278 | } |
| 279 | }; |
| 280 | const drop = async (target: TreeItem, where: "before" | "inside") => { |
| 281 | if (!drag) return; |
| 282 | const move = |
| 283 | where === "inside" |
| 284 | ? { space_id: place.space_id, parent_id: target.id, before_id: null } |
| 285 | : { space_id: place.space_id, parent_id: target.parent_id, before_id: target.id }; |
| 286 | setDrag(null); |
| 287 | await send("move", { folio_id: drag.id, move }); |
| 288 | if (where === "inside") setOpen((was) => new Set([...was, target.id])); |
| 289 | }; |
| 290 | return ( |
| 291 | <ul> |
| 292 | {tree.map((item) => ( |
| 293 | <TreeRow |
| 294 | key={item.id} |
| 295 | slug={slug} |
| 296 | place={place} |
| 297 | item={item} |
| 298 | current={current} |
| 299 | open={open} |
| 300 | toggle={toggle} |
| 301 | drag={drag} |
| 302 | setDrag={setDrag} |
| 303 | onDrop={drop} |
| 304 | onAdd={add} |
| 305 | /> |
| 306 | ))} |
| 307 | {tree.length === 0 && <li className="py-1 pr-2 pl-9 text-xs text-faint">{empty}</li>} |
| 308 | </ul> |
| 309 | ); |
| 310 | } |
| 311 | |
| 312 | function SpaceTree({ |
| 313 | slug, |
| 314 | space, |
| 315 | current, |
| 316 | drag, |
| 317 | setDrag, |
| 318 | }: { |
| 319 | slug: string; |
| 320 | space: FoliosSidebarSpace; |
| 321 | current: string | null; |
| 322 | drag: Drag; |
| 323 | setDrag: (d: Drag) => void; |
| 324 | }) { |
| 325 | const navigate = useNavigate(); |
| 326 | const { send } = useFoliosAction(slug); |
| 327 | const [collapsed, setCollapsed] = useState(false); |
| 328 | const editable = canDo(space.viewer_role, "edit"); |
| 329 | const place: Place = { space_id: space.id, key: space.id, editable }; |
| 330 | return ( |
| 331 | <li className="mt-1"> |
| 332 | <div |
| 333 | className={`${ROW} text-fg-soft`} |
| 334 | onDragOver={(e) => { |
| 335 | if (drag && editable) e.preventDefault(); |
| 336 | }} |
| 337 | onDrop={async (e) => { |
| 338 | // Dropped on the space itself: to its top, at the end. |
| 339 | e.preventDefault(); |
| 340 | if (!drag) return; |
| 341 | const id = drag.id; |
| 342 | setDrag(null); |
| 343 | await send("move", { folio_id: id, move: { space_id: space.id, parent_id: null, before_id: null } }); |
| 344 | }} |
| 345 | > |
| 346 | <Button |
| 347 | type="button" |
| 348 | onClick={() => setCollapsed(!collapsed)} |
| 349 | aria-expanded={!collapsed} |
| 350 | aria-label={collapsed ? `Show ${space.name}` : `Hide ${space.name}`} |
| 351 | variant="ghost" |
| 352 | size="icon" |
| 353 | className="size-5 rounded text-faint hover:bg-line" |
| 354 | > |
| 355 | <ChevronRight size={13} className={`transition-transform ${collapsed ? "" : "rotate-90"}`} /> |
| 356 | </Button> |
| 357 | <NavLink |
| 358 | to={spacePath(slug, space.slug)} |
| 359 | end |
| 360 | prefetch="intent" |
| 361 | className={({ isActive }) => `flex min-w-0 grow items-center gap-1.5 font-medium ${isActive ? "text-fg" : ""}`} |
| 362 | > |
| 363 | <span className="flex w-4 shrink-0 justify-center"> |
| 364 | <SpaceIcon space={space} size={14} /> |
| 365 | </span> |
| 366 | <span className="min-w-0 truncate">{space.name}</span> |
| 367 | </NavLink> |
| 368 | {canDo(space.viewer_role, "manage") && ( |
| 369 | <Hint label="Space settings"> |
| 370 | <NavLink to={`${spacePath(slug, space.slug)}/settings`} aria-label={`${space.name} settings`} className={ROW_BUTTON}> |
| 371 | <Settings size={13} /> |
| 372 | </NavLink> |
| 373 | </Hint> |
| 374 | )} |
| 375 | {editable && ( |
| 376 | <Hint label={`New doc in ${space.name}`}> |
| 377 | <button |
| 378 | type="button" |
| 379 | onClick={async () => { |
| 380 | const made = await send<{ path: string }>("create", { folio: { kind: "doc", space_id: space.id } }); |
| 381 | if (made.ok) navigate(made.value.path); |
| 382 | }} |
| 383 | aria-label={`New doc in ${space.name}`} |
| 384 | className={ROW_BUTTON} |
| 385 | > |
| 386 | <Plus size={13} /> |
| 387 | </button> |
| 388 | </Hint> |
| 389 | )} |
| 390 | </div> |
| 391 | {!collapsed && <Tree slug={slug} place={place} nodes={space.tree} current={current} drag={drag} setDrag={setDrag} empty="Nothing here yet." />} |
| 392 | </li> |
| 393 | ); |
| 394 | } |
| 395 | |
| 396 | function RepoFolderRows({ slug, repo, folder, depth, current }: { slug: string; repo: string; folder: RepoFolder; depth: number; current: string }) { |
| 397 | const [open, setOpen] = useState<Set<string>>(() => new Set(folder.folders.filter((f) => current.includes(`/${f.path}/`)).map((f) => f.path))); |
| 398 | return ( |
| 399 | <> |
| 400 | {folder.files.map((f) => ( |
| 401 | <li key={f.path}> |
| 402 | <NavLink |
| 403 | to={repoFilePath(slug, repo, f.path)} |
| 404 | prefetch="intent" |
| 405 | className={({ isActive }) => `${ROW} ${isActive ? "bg-sidebar-accent font-medium text-fg" : "text-muted hover:bg-sidebar-accent/60 hover:text-fg"}`} |
| 406 | style={{ paddingLeft: 26 + depth * 14 }} |
| 407 | > |
| 408 | <FileText size={14} className="shrink-0 text-faint" aria-hidden="true" /> |
| 409 | <span className="min-w-0 truncate">{f.title}</span> |
| 410 | </NavLink> |
| 411 | </li> |
| 412 | ))} |
| 413 | {folder.folders.map((sub) => { |
| 414 | const expanded = open.has(sub.path); |
| 415 | return ( |
| 416 | <li key={sub.path}> |
| 417 | <button |
| 418 | type="button" |
| 419 | aria-expanded={expanded} |
| 420 | onClick={() => |
| 421 | setOpen((was) => { |
| 422 | const next = new Set(was); |
| 423 | if (next.has(sub.path)) next.delete(sub.path); |
| 424 | else next.add(sub.path); |
| 425 | return next; |
| 426 | }) |
| 427 | } |
| 428 | className={`${ROW} w-full text-muted hover:bg-sidebar-accent/60 hover:text-fg`} |
| 429 | style={{ paddingLeft: 4 + (depth + 1) * 14 }} |
| 430 | > |
| 431 | <ChevronRight size={13} className={`shrink-0 text-faint transition-transform ${expanded ? "rotate-90" : ""}`} /> |
| 432 | <Folder size={14} className="shrink-0 text-faint" aria-hidden="true" /> |
| 433 | <span className="min-w-0 truncate">{sub.name}</span> |
| 434 | </button> |
| 435 | {expanded && ( |
| 436 | <ul> |
| 437 | <RepoFolderRows slug={slug} repo={repo} folder={sub} depth={depth + 1} current={current} /> |
| 438 | </ul> |
| 439 | )} |
| 440 | </li> |
| 441 | ); |
| 442 | })} |
| 443 | </> |
| 444 | ); |
| 445 | } |
| 446 | |
| 447 | /** A project's docs folder: read-only, its files as folders, from the repository's default branch. */ |
| 448 | function RepoTree({ slug, space }: { slug: string; space: DocRepoSpace }) { |
| 449 | const { pathname } = useSidebarLocation(); |
| 450 | const base = `/${slug}/-/artifacts/repo/${space.repo}/`; |
| 451 | const here = decodeURIComponent(pathname).startsWith(base); |
| 452 | const [collapsed, setCollapsed] = useState(!here); |
| 453 | const root = useMemo(() => repoFolders(space.files), [space.files]); |
| 454 | return ( |
| 455 | <li className="mt-1"> |
| 456 | <div className={`${ROW} text-fg-soft`}> |
| 457 | <Button |
| 458 | type="button" |
| 459 | onClick={() => setCollapsed(!collapsed)} |
| 460 | aria-expanded={!collapsed} |
| 461 | aria-label={collapsed ? `Show ${space.repo}'s docs` : `Hide ${space.repo}'s docs`} |
| 462 | variant="ghost" |
| 463 | size="icon" |
| 464 | className="size-5 rounded text-faint hover:bg-line" |
| 465 | > |
| 466 | <ChevronRight size={13} className={`transition-transform ${collapsed ? "" : "rotate-90"}`} /> |
| 467 | </Button> |
| 468 | <span className="flex w-4 shrink-0 justify-center"> |
| 469 | <FolderGit2 size={14} className="text-faint" aria-hidden="true" /> |
| 470 | </span> |
| 471 | <span className="min-w-0 grow truncate font-medium">{space.repo}</span> |
| 472 | </div> |
| 473 | {!collapsed && ( |
| 474 | <ul> |
| 475 | <RepoFolderRows slug={slug} repo={space.repo} folder={root} depth={0} current={decodeURIComponent(pathname)} /> |
| 476 | {space.files.length === 0 && ( |
| 477 | <li className="py-1 pr-2 pl-9 text-xs text-faint">{space.indexed_at ? "No docs folder or README." : "Reading its docs…"}</li> |
| 478 | )} |
| 479 | </ul> |
| 480 | )} |
| 481 | </li> |
| 482 | ); |
| 483 | } |
| 484 | |
| 485 | function RefRow({ folio, trailing }: { folio: FolioRef; trailing?: ReactNode }) { |
| 486 | return ( |
| 487 | <li> |
| 488 | <SideLink to={folio.path} icon={<FolioGlyph folio={folio} size={14} />} trailing={trailing}> |
| 489 | {folio.title || "Untitled"} |
| 490 | </SideLink> |
| 491 | </li> |
| 492 | ); |
| 493 | } |
| 494 | |
| 495 | export function FoliosSidebar({ slug, onClose }: { slug: string; onClose?: () => void }) { |
| 496 | const data = useFoliosData(); |
| 497 | const navigate = useNavigate(); |
| 498 | const params = useParams(); |
| 499 | const current = folioIdFrom(params.folio); |
| 500 | const [drag, setDrag] = useState<Drag>(null); |
| 501 | const [query, setQuery] = useState(""); |
| 502 | const [addingRepo, setAddingRepo] = useState(false); |
| 503 | const [error, setError] = useState<string | null>(null); |
| 504 | const { revalidate } = useRevalidator(); |
| 505 | const sidebar = data?.sidebar; |
| 506 | const make = async (kind: FolioKind) => { |
| 507 | setError(null); |
| 508 | const made = await foliosRequest<{ path: string }>(slug, "create", { folio: { kind } }); |
| 509 | if (made.ok) { |
| 510 | void revalidate(); |
| 511 | navigate(made.value.path); |
| 512 | } else setError(made.error.message); |
| 513 | }; |
| 514 | return ( |
| 515 | <div className="flex h-full flex-col"> |
| 516 | <div className="flex h-9 shrink-0 items-center gap-1 pr-1 pl-3"> |
| 517 | <h2 className="min-w-0 grow truncate text-[0.6875rem] font-medium tracking-wide text-faint uppercase">Artifacts</h2> |
| 518 | <NewMenu onMake={make}> |
| 519 | <Button type="button" aria-label="New" variant="ghost" className="h-8 gap-0.5 px-1.5 text-faint"> |
| 520 | <Plus size={16} /> |
| 521 | <ChevronDown size={12} /> |
| 522 | </Button> |
| 523 | </NewMenu> |
| 524 | {onClose && ( |
| 525 | <Button type="button" aria-label="Close menu" onClick={onClose} variant="ghost" size="icon-sm" className="text-faint"> |
| 526 | <X size={16} /> |
| 527 | </Button> |
| 528 | )} |
| 529 | </div> |
| 530 | <nav aria-label="Artifacts" className="min-h-0 grow overflow-y-auto px-2.5 pt-3 pb-4 scrollbar-thin"> |
| 531 | <form |
| 532 | role="search" |
| 533 | onSubmit={(e) => { |
| 534 | e.preventDefault(); |
| 535 | navigate(`/${slug}/-/artifacts?q=${encodeURIComponent(query.trim())}`); |
| 536 | }} |
| 537 | className="mb-2" |
| 538 | > |
| 539 | <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"> |
| 540 | <Search size={14} aria-hidden="true" /> |
| 541 | <span className="sr-only">Search artifacts</span> |
| 542 | <input |
| 543 | value={query} |
| 544 | onChange={(e) => setQuery(e.target.value)} |
| 545 | placeholder="Search artifacts" |
| 546 | className="min-w-0 grow bg-transparent text-fg outline-none placeholder:text-faint" |
| 547 | /> |
| 548 | </label> |
| 549 | </form> |
| 550 | {error && <p className="mb-2 px-2 text-xs text-danger">{error}</p>} |
| 551 | <div className="space-y-px"> |
| 552 | <SideLink to={`/${slug}/-/artifacts`} end icon={<Home size={15} />}> |
| 553 | Home |
| 554 | </SideLink> |
| 555 | <SideLink to={`/${slug}/-/artifacts/templates`} icon={<LayoutTemplate size={15} />}> |
| 556 | Templates |
| 557 | </SideLink> |
| 558 | </div> |
| 559 | {sidebar === undefined || sidebar === null ? ( |
| 560 | sidebar === null ? ( |
| 561 | <p className="mt-4 px-2 text-xs leading-relaxed text-faint">Artifacts didn't answer. Your spaces will show here once it does.</p> |
| 562 | ) : ( |
| 563 | <div className="mt-5 space-y-3 px-2" aria-busy="true"> |
| 564 | {[0, 1, 2, 3].map((i) => ( |
| 565 | <Skeleton key={i} className="h-3 w-36" /> |
| 566 | ))} |
| 567 | </div> |
| 568 | ) |
| 569 | ) : ( |
| 570 | <> |
| 571 | {sidebar.favorites.length > 0 && ( |
| 572 | <Section title="Favorites"> |
| 573 | <ul className="space-y-px"> |
| 574 | {sidebar.favorites.map((f) => ( |
| 575 | <RefRow key={f.id} folio={f} trailing={<Star size={12} className="shrink-0 fill-current text-warn/80" aria-hidden="true" />} /> |
| 576 | ))} |
| 577 | </ul> |
| 578 | </Section> |
| 579 | )} |
| 580 | <Section |
| 581 | title="Spaces" |
| 582 | action={ |
| 583 | <DropdownMenu> |
| 584 | <Hint label="Browse or make spaces"> |
| 585 | <DropdownMenuTrigger asChild> |
| 586 | <Button type="button" aria-label="Browse or make spaces" variant="ghost" size="icon" className="size-6 rounded text-faint"> |
| 587 | <Plus size={13} /> |
| 588 | </Button> |
| 589 | </DropdownMenuTrigger> |
| 590 | </Hint> |
| 591 | <DropdownMenuContent align="end" className="w-44"> |
| 592 | <DropdownMenuItem onSelect={() => navigate(`/${slug}/-/artifacts/spaces`)}> |
| 593 | <Compass size={14} /> Browse spaces |
| 594 | </DropdownMenuItem> |
| 595 | <DropdownMenuSeparator /> |
| 596 | <DropdownMenuItem onSelect={() => navigate(`/${slug}/-/artifacts/spaces/new`)}> |
| 597 | <Plus size={14} /> New space |
| 598 | </DropdownMenuItem> |
| 599 | </DropdownMenuContent> |
| 600 | </DropdownMenu> |
| 601 | } |
| 602 | > |
| 603 | <ul> |
| 604 | {sidebar.spaces.map((space) => ( |
| 605 | <SpaceTree key={space.id} slug={slug} space={space} current={current} drag={drag} setDrag={setDrag} /> |
| 606 | ))} |
| 607 | {sidebar.spaces.length === 0 && <li className="px-2 py-1 text-xs text-faint">No spaces yet.</li>} |
| 608 | </ul> |
| 609 | </Section> |
| 610 | <Section |
| 611 | title="Private" |
| 612 | action={ |
| 613 | <Hint label="New private doc"> |
| 614 | <Button |
| 615 | type="button" |
| 616 | onClick={() => make("doc")} |
| 617 | aria-label="New private doc" |
| 618 | variant="ghost" |
| 619 | size="icon" |
| 620 | className="size-6 rounded text-faint" |
| 621 | > |
| 622 | <Plus size={13} /> |
| 623 | </Button> |
| 624 | </Hint> |
| 625 | } |
| 626 | > |
| 627 | <div |
| 628 | onDragOver={(e) => { |
| 629 | if (drag) e.preventDefault(); |
| 630 | }} |
| 631 | onDrop={async (e) => { |
| 632 | // Dropped on Private's empty space: to its top, at the end. |
| 633 | if (e.target !== e.currentTarget || !drag) return; |
| 634 | e.preventDefault(); |
| 635 | const id = drag.id; |
| 636 | setDrag(null); |
| 637 | const moved = await foliosRequest(slug, "move", { folio_id: id, move: { space_id: null, parent_id: null, before_id: null } }); |
| 638 | if (moved.ok) void revalidate(); |
| 639 | else setError(moved.error.message); |
| 640 | }} |
| 641 | > |
| 642 | <Tree |
| 643 | slug={slug} |
| 644 | place={{ space_id: null, key: "private", editable: true }} |
| 645 | nodes={sidebar.private_tree} |
| 646 | current={current} |
| 647 | drag={drag} |
| 648 | setDrag={setDrag} |
| 649 | empty="Only you can see what's here." |
| 650 | /> |
| 651 | </div> |
| 652 | </Section> |
| 653 | {sidebar.shared.length > 0 && ( |
| 654 | <Section title="Shared"> |
| 655 | <ul className="space-y-px"> |
| 656 | {sidebar.shared.map((f) => ( |
| 657 | <RefRow key={f.id} folio={f} /> |
| 658 | ))} |
| 659 | </ul> |
| 660 | </Section> |
| 661 | )} |
| 662 | <div className="mt-4 space-y-px"> |
| 663 | {!!sidebar.stale_count && ( |
| 664 | <SideLink |
| 665 | to={`/${slug}/-/artifacts/stale`} |
| 666 | icon={<AlertTriangle size={15} className="text-warn" />} |
| 667 | trailing={<span className="text-[0.6875rem] text-warn tabular-nums">{sidebar.stale_count}</span>} |
| 668 | > |
| 669 | Possibly out of date |
| 670 | </SideLink> |
| 671 | )} |
| 672 | </div> |
| 673 | <Section |
| 674 | title="Projects' docs" |
| 675 | open={(sidebar.repos ?? []).length > 0} |
| 676 | action={ |
| 677 | <Hint label="Show a project's docs"> |
| 678 | <Button |
| 679 | type="button" |
| 680 | onClick={() => setAddingRepo(true)} |
| 681 | aria-label="Show a project's docs" |
| 682 | variant="ghost" |
| 683 | size="icon" |
| 684 | className="size-6 rounded text-faint" |
| 685 | > |
| 686 | <Plus size={13} /> |
| 687 | </Button> |
| 688 | </Hint> |
| 689 | } |
| 690 | > |
| 691 | <ul> |
| 692 | {(sidebar.repos ?? []).map((space) => ( |
| 693 | <RepoTree key={space.id} slug={slug} space={space} /> |
| 694 | ))} |
| 695 | {(sidebar.repos ?? []).length === 0 && ( |
| 696 | <li> |
| 697 | <button type="button" onClick={() => setAddingRepo(true)} className="px-2 py-1 text-left text-xs text-faint hover:text-fg"> |
| 698 | Show a repository's docs folder here, read-only. |
| 699 | </button> |
| 700 | </li> |
| 701 | )} |
| 702 | </ul> |
| 703 | </Section> |
| 704 | <div className="mt-4 space-y-px"> |
| 705 | <SideLink |
| 706 | to={`/${slug}/-/artifacts/trash`} |
| 707 | icon={<Trash2 size={15} />} |
| 708 | trailing={sidebar.trash_count ? <span className="text-[0.6875rem] text-faint tabular-nums">{sidebar.trash_count}</span> : undefined} |
| 709 | > |
| 710 | Trash |
| 711 | </SideLink> |
| 712 | </div> |
| 713 | <RepoDocsDialog |
| 714 | slug={slug} |
| 715 | open={addingRepo} |
| 716 | onOpenChange={setAddingRepo} |
| 717 | shown={(sidebar.repos ?? []).map((r) => r.repo.toLowerCase())} |
| 718 | onAdded={() => void revalidate()} |
| 719 | /> |
| 720 | </> |
| 721 | )} |
| 722 | </nav> |
| 723 | </div> |
| 724 | ); |
| 725 | } |