| 1 | /** |
| 2 | * Artifacts mode's sidebar (beside the rail; docs.g1t.sh/guides/artifacts/, |
| 3 | * "The sidebar"): search, Home, New and Templates; Favorites; the spaces |
| 4 | * shown (joined open spaces, team spaces, members-only spaces) each with |
| 5 | * its tree; Private, everything of yours in no space; Shared, the tops of |
| 6 | * what others shared with you; then what's possibly out of date, |
| 7 | * projects' docs folders (read-only) and the trash. Rows are dragged to |
| 8 | * reorder them or to put one inside a doc. |
| 9 | */ |
| 10 | import { FOLIO_KINDS, folioIdFrom, folioSlug, type DocRepoSpace, type FolioKind, type FolioRef, type FolioTreeNode, type FoliosSidebarSpace } from "@g1t/contracts"; |
| 11 | import { AlertTriangle, ChevronDown, ChevronRight, Compass, FileText, Folder, FolderGit2, Home, LayoutTemplate, Lock, Plus, Search, Settings, Star, Trash2, X } from "lucide-react"; |
| 12 | import { type DragEvent, type ReactNode, useEffect, useMemo, useState } from "react"; |
| 13 | import { NavLink, useLocation, useNavigate, useParams, useRevalidator } from "react-router"; |
| 14 | |
| 15 | import { buildTree, canDo, pathTo, repoFilePath, repoFolders, spacePath, type RepoFolder, type TreeItem } from "../../lib/folios"; |
| 16 | import { Button } from "../ui/button"; |
| 17 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu"; |
| 18 | import { Hint } from "../ui/hint"; |
| 19 | import { Skeleton } from "../ui/skeleton"; |
| 20 | import { foliosRequest, useFoliosAction, useFoliosData } from "./actions"; |
| 21 | import { RepoDocsDialog } from "./doc/code"; |
| 22 | import { FOLIO_KIND_UI, FolioGlyph, KindIcon } from "./kinds"; |
| 23 | import { SpaceIcon } from "./parts"; |
| 24 | |
| 25 | const ROW = "group flex h-8 items-center gap-1.5 rounded-md pr-1 text-[0.8125rem] transition-colors"; |
| 26 | const ROW_BUTTON = "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"; |
| 27 | |
| 28 | function SideLink({ to, end, icon, children, trailing }: { to: string; end?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode }) { |
| 29 | return ( |
| 30 | <NavLink to={to} end={end} prefetch="intent" className={({ isActive }) => `${ROW} pl-2 ${isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`}> |
| 31 | <span className="flex w-5 shrink-0 justify-center text-faint">{icon}</span> |
| 32 | <span className="min-w-0 grow truncate">{children}</span> |
| 33 | {trailing} |
| 34 | </NavLink> |
| 35 | ); |
| 36 | } |
| 37 | |
| 38 | function Section({ title, children, action, open: initially = true }: { title: string; children: ReactNode; action?: ReactNode; open?: boolean }) { |
| 39 | const [open, setOpen] = useState(initially); |
| 40 | return ( |
| 41 | <section className="mt-4"> |
| 42 | <div className="mb-0.5 flex items-center gap-1 pr-1"> |
| 43 | <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"> |
| 44 | <ChevronRight size={12} className={`transition-transform ${open ? "rotate-90" : ""}`} /> |
| 45 | {title} |
| 46 | </button> |
| 47 | {action} |
| 48 | </div> |
| 49 | {open && children} |
| 50 | </section> |
| 51 | ); |
| 52 | } |
| 53 | |
| 54 | /** "New ▾": the four kinds, the ones not here yet shown as coming. */ |
| 55 | export function NewMenu({ onMake, children, align = "end" }: { onMake: (kind: FolioKind) => void; children: ReactNode; align?: "start" | "end" }) { |
| 56 | return ( |
| 57 | <DropdownMenu> |
| 58 | <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> |
| 59 | <DropdownMenuContent align={align} className="w-52"> |
| 60 | {FOLIO_KINDS.map((kind) => { |
| 61 | const ui = FOLIO_KIND_UI[kind]; |
| 62 | return ( |
| 63 | <DropdownMenuItem key={kind} disabled={!ui.ready} onSelect={() => onMake(kind)}> |
| 64 | <KindIcon kind={kind} size={13} box={22} /> |
| 65 | <span className="grow">{ui.label}</span> |
| 66 | {!ui.ready && <span className="text-[0.625rem] text-faint">Coming soon</span>} |
| 67 | </DropdownMenuItem> |
| 68 | ); |
| 69 | })} |
| 70 | </DropdownMenuContent> |
| 71 | </DropdownMenu> |
| 72 | ); |
| 73 | } |
| 74 | |
| 75 | type Drag = { id: string; place: string } | null; |
| 76 | |
| 77 | /** Where a tree lives: a space (by id) or the viewer's Private (null). */ |
| 78 | type Place = { space_id: string | null; key: string; editable: boolean }; |
| 79 | |
| 80 | function TreeRow({ slug, place, item, current, open, toggle, drag, setDrag, onDrop, onAdd }: { slug: string; place: Place; item: TreeItem; current: string | null; open: Set<string>; toggle: (id: string) => void; drag: Drag; setDrag: (d: Drag) => void; onDrop: (target: TreeItem, where: "before" | "inside") => void; onAdd: (parent: string) => void }) { |
| 81 | const [over, setOver] = useState<"before" | "inside" | null>(null); |
| 82 | const expanded = open.has(item.id); |
| 83 | const active = current === item.id; |
| 84 | const holds = item.kind === "doc"; |
| 85 | const zone = (e: DragEvent<HTMLElement>): "before" | "inside" => { |
| 86 | if (!holds) return "before"; |
| 87 | const box = e.currentTarget.getBoundingClientRect(); |
| 88 | return e.clientY - box.top < box.height * 0.35 ? "before" : "inside"; |
| 89 | }; |
| 90 | return ( |
| 91 | <li> |
| 92 | <div |
| 93 | draggable={place.editable} |
| 94 | onDragStart={(e) => { |
| 95 | e.dataTransfer.effectAllowed = "move"; |
| 96 | e.dataTransfer.setData("text/plain", item.id); |
| 97 | setDrag({ id: item.id, place: place.key }); |
| 98 | }} |
| 99 | onDragEnd={() => setDrag(null)} |
| 100 | onDragOver={(e) => { |
| 101 | if (!drag || drag.id === item.id || !place.editable) return; |
| 102 | e.preventDefault(); |
| 103 | setOver(zone(e)); |
| 104 | }} |
| 105 | onDragLeave={() => setOver(null)} |
| 106 | onDrop={(e) => { |
| 107 | e.preventDefault(); |
| 108 | const where = zone(e); |
| 109 | setOver(null); |
| 110 | if (drag && drag.id !== item.id) onDrop(item, where); |
| 111 | }} |
| 112 | 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" : ""}`} |
| 113 | style={{ paddingLeft: 4 + item.depth * 14 }} |
| 114 | > |
| 115 | {over === "before" && <span className="pointer-events-none absolute inset-x-1 -top-px h-0.5 rounded bg-accent" aria-hidden="true" />} |
| 116 | <button |
| 117 | type="button" |
| 118 | onClick={() => toggle(item.id)} |
| 119 | aria-label={expanded ? "Collapse" : "Expand"} |
| 120 | aria-expanded={expanded} |
| 121 | className={`flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg ${item.children.length ? "" : "invisible"}`} |
| 122 | > |
| 123 | <ChevronRight size={13} className={`transition-transform ${expanded ? "rotate-90" : ""}`} /> |
| 124 | </button> |
| 125 | <NavLink to={`/${slug}/-/artifacts/${folioSlug(item.title, item.id)}`} prefetch="intent" className="flex min-w-0 grow items-center gap-1.5"> |
| 126 | <span className="flex w-4 shrink-0 justify-center"> |
| 127 | <FolioGlyph folio={item} size={14} /> |
| 128 | </span> |
| 129 | <span className="min-w-0 truncate">{item.title || "Untitled"}</span> |
| 130 | {item.restricted && ( |
| 131 | <Hint label="Only people invited"> |
| 132 | <Lock size={11} className="shrink-0 text-faint" aria-label="Only people invited" /> |
| 133 | </Hint> |
| 134 | )} |
| 135 | {item.stale && ( |
| 136 | <Hint label="Possibly out of date: code it cites changed"> |
| 137 | <span className="ml-auto size-1.5 shrink-0 rounded-full bg-warn" aria-label="Possibly out of date" /> |
| 138 | </Hint> |
| 139 | )} |
| 140 | </NavLink> |
| 141 | {place.editable && holds && ( |
| 142 | <Hint label="Add a doc inside"> |
| 143 | <button type="button" onClick={() => onAdd(item.id)} aria-label={`Add a doc inside ${item.title || "Untitled"}`} className={ROW_BUTTON}> |
| 144 | <Plus size={13} /> |
| 145 | </button> |
| 146 | </Hint> |
| 147 | )} |
| 148 | </div> |
| 149 | {expanded && item.children.length > 0 && ( |
| 150 | <ul> |
| 151 | {item.children.map((child) => ( |
| 152 | <TreeRow key={child.id} slug={slug} place={place} item={child} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={onDrop} onAdd={onAdd} /> |
| 153 | ))} |
| 154 | </ul> |
| 155 | )} |
| 156 | </li> |
| 157 | ); |
| 158 | } |
| 159 | |
| 160 | /** A tree of folios in one place, opened to the one being read; drag to reorder or nest. */ |
| 161 | function Tree({ slug, place, nodes, current, drag, setDrag, empty }: { slug: string; place: Place; nodes: FolioTreeNode[]; current: string | null; drag: Drag; setDrag: (d: Drag) => void; empty: string }) { |
| 162 | const navigate = useNavigate(); |
| 163 | const { send } = useFoliosAction(slug); |
| 164 | const tree = useMemo(() => buildTree(nodes), [nodes]); |
| 165 | const [open, setOpen] = useState<Set<string>>(() => new Set(pathTo(nodes, current))); |
| 166 | // Opening an artifact opens the way to it. |
| 167 | useEffect(() => { |
| 168 | if (!current) return; |
| 169 | const path = pathTo(nodes, current); |
| 170 | if (path.length) setOpen((was) => new Set([...was, ...path])); |
| 171 | }, [current, nodes]); |
| 172 | const toggle = (id: string) => |
| 173 | setOpen((was) => { |
| 174 | const next = new Set(was); |
| 175 | if (next.has(id)) next.delete(id); |
| 176 | else next.add(id); |
| 177 | return next; |
| 178 | }); |
| 179 | const add = async (parent: string) => { |
| 180 | const made = await send<{ path: string }>("create", { folio: { kind: "doc", parent_id: parent } }); |
| 181 | if (made.ok) { |
| 182 | setOpen((was) => new Set([...was, parent])); |
| 183 | navigate(made.value.path); |
| 184 | } |
| 185 | }; |
| 186 | const drop = async (target: TreeItem, where: "before" | "inside") => { |
| 187 | if (!drag) return; |
| 188 | const move = where === "inside" ? { space_id: place.space_id, parent_id: target.id, before_id: null } : { space_id: place.space_id, parent_id: target.parent_id, before_id: target.id }; |
| 189 | setDrag(null); |
| 190 | await send("move", { folio_id: drag.id, move }); |
| 191 | if (where === "inside") setOpen((was) => new Set([...was, target.id])); |
| 192 | }; |
| 193 | return ( |
| 194 | <ul> |
| 195 | {tree.map((item) => ( |
| 196 | <TreeRow key={item.id} slug={slug} place={place} item={item} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={drop} onAdd={add} /> |
| 197 | ))} |
| 198 | {tree.length === 0 && <li className="py-1 pr-2 pl-9 text-xs text-faint">{empty}</li>} |
| 199 | </ul> |
| 200 | ); |
| 201 | } |
| 202 | |
| 203 | function SpaceTree({ slug, space, current, drag, setDrag }: { slug: string; space: FoliosSidebarSpace; current: string | null; drag: Drag; setDrag: (d: Drag) => void }) { |
| 204 | const navigate = useNavigate(); |
| 205 | const { send } = useFoliosAction(slug); |
| 206 | const [collapsed, setCollapsed] = useState(false); |
| 207 | const editable = canDo(space.viewer_role, "edit"); |
| 208 | const place: Place = { space_id: space.id, key: space.id, editable }; |
| 209 | return ( |
| 210 | <li className="mt-1"> |
| 211 | <div |
| 212 | className={`${ROW} text-fg-soft`} |
| 213 | onDragOver={(e) => { |
| 214 | if (drag && editable) e.preventDefault(); |
| 215 | }} |
| 216 | onDrop={async (e) => { |
| 217 | // Dropped on the space itself: to its top, at the end. |
| 218 | e.preventDefault(); |
| 219 | if (!drag) return; |
| 220 | const id = drag.id; |
| 221 | setDrag(null); |
| 222 | await send("move", { folio_id: id, move: { space_id: space.id, parent_id: null, before_id: null } }); |
| 223 | }} |
| 224 | > |
| 225 | <Button type="button" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed} aria-label={collapsed ? `Show ${space.name}` : `Hide ${space.name}`} variant="ghost" size="icon" className="size-5 rounded text-faint hover:bg-line"> |
| 226 | <ChevronRight size={13} className={`transition-transform ${collapsed ? "" : "rotate-90"}`} /> |
| 227 | </Button> |
| 228 | <NavLink to={spacePath(slug, space.slug)} end prefetch="intent" className={({ isActive }) => `flex min-w-0 grow items-center gap-1.5 font-medium ${isActive ? "text-fg" : ""}`}> |
| 229 | <span className="flex w-4 shrink-0 justify-center"> |
| 230 | <SpaceIcon space={space} size={14} /> |
| 231 | </span> |
| 232 | <span className="min-w-0 truncate">{space.name}</span> |
| 233 | </NavLink> |
| 234 | {canDo(space.viewer_role, "manage") && ( |
| 235 | <Hint label="Space settings"> |
| 236 | <NavLink to={`${spacePath(slug, space.slug)}/settings`} aria-label={`${space.name} settings`} className={ROW_BUTTON}> |
| 237 | <Settings size={13} /> |
| 238 | </NavLink> |
| 239 | </Hint> |
| 240 | )} |
| 241 | {editable && ( |
| 242 | <Hint label={`New doc in ${space.name}`}> |
| 243 | <button |
| 244 | type="button" |
| 245 | onClick={async () => { |
| 246 | const made = await send<{ path: string }>("create", { folio: { kind: "doc", space_id: space.id } }); |
| 247 | if (made.ok) navigate(made.value.path); |
| 248 | }} |
| 249 | aria-label={`New doc in ${space.name}`} |
| 250 | className={ROW_BUTTON} |
| 251 | > |
| 252 | <Plus size={13} /> |
| 253 | </button> |
| 254 | </Hint> |
| 255 | )} |
| 256 | </div> |
| 257 | {!collapsed && <Tree slug={slug} place={place} nodes={space.tree} current={current} drag={drag} setDrag={setDrag} empty="Nothing here yet." />} |
| 258 | </li> |
| 259 | ); |
| 260 | } |
| 261 | |
| 262 | function RepoFolderRows({ slug, repo, folder, depth, current }: { slug: string; repo: string; folder: RepoFolder; depth: number; current: string }) { |
| 263 | const [open, setOpen] = useState<Set<string>>(() => new Set(folder.folders.filter((f) => current.includes(`/${f.path}/`)).map((f) => f.path))); |
| 264 | return ( |
| 265 | <> |
| 266 | {folder.files.map((f) => ( |
| 267 | <li key={f.path}> |
| 268 | <NavLink to={repoFilePath(slug, repo, f.path)} 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 }}> |
| 269 | <FileText size={14} className="shrink-0 text-faint" aria-hidden="true" /> |
| 270 | <span className="min-w-0 truncate">{f.title}</span> |
| 271 | </NavLink> |
| 272 | </li> |
| 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. */ |
| 309 | function RepoTree({ slug, space }: { slug: string; space: DocRepoSpace }) { |
| 310 | const { pathname } = useLocation(); |
| 311 | const base = `/${slug}/-/artifacts/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`} variant="ghost" size="icon" className="size-5 rounded text-faint hover:bg-line"> |
| 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 | |
| 336 | function RefRow({ folio, trailing }: { folio: FolioRef; trailing?: ReactNode }) { |
| 337 | return ( |
| 338 | <li> |
| 339 | <SideLink to={folio.path} icon={<FolioGlyph folio={folio} size={14} />} trailing={trailing}> |
| 340 | {folio.title || "Untitled"} |
| 341 | </SideLink> |
| 342 | </li> |
| 343 | ); |
| 344 | } |
| 345 | |
| 346 | export function FoliosSidebar({ slug, onClose }: { slug: string; onClose?: () => void }) { |
| 347 | const data = useFoliosData(); |
| 348 | const navigate = useNavigate(); |
| 349 | const params = useParams(); |
| 350 | const current = folioIdFrom(params.folio); |
| 351 | const [drag, setDrag] = useState<Drag>(null); |
| 352 | const [query, setQuery] = useState(""); |
| 353 | const [addingRepo, setAddingRepo] = useState(false); |
| 354 | const [error, setError] = useState<string | null>(null); |
| 355 | const { revalidate } = useRevalidator(); |
| 356 | const sidebar = data?.sidebar; |
| 357 | const make = async (kind: FolioKind) => { |
| 358 | setError(null); |
| 359 | const made = await foliosRequest<{ path: string }>(slug, "create", { folio: { kind } }); |
| 360 | if (made.ok) { |
| 361 | void revalidate(); |
| 362 | navigate(made.value.path); |
| 363 | } else setError(made.error.message); |
| 364 | }; |
| 365 | return ( |
| 366 | <div className="flex h-full flex-col"> |
| 367 | <div className="flex h-9 shrink-0 items-center gap-1 pr-1 pl-3"> |
| 368 | <h2 className="min-w-0 grow truncate text-xs font-medium text-faint">Artifacts</h2> |
| 369 | <NewMenu onMake={make}> |
| 370 | <Button type="button" aria-label="New" variant="ghost" className="h-8 gap-0.5 px-1.5 text-faint"> |
| 371 | <Plus size={16} /> |
| 372 | <ChevronDown size={12} /> |
| 373 | </Button> |
| 374 | </NewMenu> |
| 375 | {onClose && ( |
| 376 | <Button type="button" aria-label="Close menu" onClick={onClose} variant="ghost" size="icon-sm" className="text-faint"> |
| 377 | <X size={16} /> |
| 378 | </Button> |
| 379 | )} |
| 380 | </div> |
| 381 | <nav aria-label="Artifacts" className="min-h-0 grow overflow-y-auto px-2.5 pt-3 pb-4 [scrollbar-width:thin]"> |
| 382 | <form |
| 383 | role="search" |
| 384 | onSubmit={(e) => { |
| 385 | e.preventDefault(); |
| 386 | navigate(`/${slug}/-/artifacts?q=${encodeURIComponent(query.trim())}`); |
| 387 | }} |
| 388 | className="mb-2" |
| 389 | > |
| 390 | <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"> |
| 391 | <Search size={14} aria-hidden="true" /> |
| 392 | <span className="sr-only">Search artifacts</span> |
| 393 | <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search artifacts" className="min-w-0 grow bg-transparent text-fg outline-none placeholder:text-faint" /> |
| 394 | </label> |
| 395 | </form> |
| 396 | {error && <p className="mb-2 px-2 text-xs text-danger">{error}</p>} |
| 397 | <div className="space-y-px"> |
| 398 | <SideLink to={`/${slug}/-/artifacts`} end icon={<Home size={15} />}> |
| 399 | Home |
| 400 | </SideLink> |
| 401 | <SideLink to={`/${slug}/-/artifacts/templates`} icon={<LayoutTemplate size={15} />}> |
| 402 | Templates |
| 403 | </SideLink> |
| 404 | </div> |
| 405 | {sidebar === undefined || sidebar === null ? ( |
| 406 | sidebar === null ? ( |
| 407 | <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> |
| 408 | ) : ( |
| 409 | <div className="mt-5 space-y-3 px-2" aria-busy="true"> |
| 410 | {[0, 1, 2, 3].map((i) => ( |
| 411 | <Skeleton key={i} className="h-3 w-36" /> |
| 412 | ))} |
| 413 | </div> |
| 414 | ) |
| 415 | ) : ( |
| 416 | <> |
| 417 | {sidebar.favorites.length > 0 && ( |
| 418 | <Section title="Favorites"> |
| 419 | <ul className="space-y-px"> |
| 420 | {sidebar.favorites.map((f) => ( |
| 421 | <RefRow key={f.id} folio={f} trailing={<Star size={12} className="shrink-0 fill-current text-warn/80" aria-hidden="true" />} /> |
| 422 | ))} |
| 423 | </ul> |
| 424 | </Section> |
| 425 | )} |
| 426 | <Section |
| 427 | title="Spaces" |
| 428 | action={ |
| 429 | <DropdownMenu> |
| 430 | <Hint label="Browse or make spaces"> |
| 431 | <DropdownMenuTrigger asChild> |
| 432 | <Button type="button" aria-label="Browse or make spaces" variant="ghost" size="icon" className="size-6 rounded text-faint"> |
| 433 | <Plus size={13} /> |
| 434 | </Button> |
| 435 | </DropdownMenuTrigger> |
| 436 | </Hint> |
| 437 | <DropdownMenuContent align="end" className="w-44"> |
| 438 | <DropdownMenuItem onSelect={() => navigate(`/${slug}/-/artifacts/spaces`)}> |
| 439 | <Compass size={14} /> Browse spaces |
| 440 | </DropdownMenuItem> |
| 441 | <DropdownMenuSeparator /> |
| 442 | <DropdownMenuItem onSelect={() => navigate(`/${slug}/-/artifacts/spaces/new`)}> |
| 443 | <Plus size={14} /> New space |
| 444 | </DropdownMenuItem> |
| 445 | </DropdownMenuContent> |
| 446 | </DropdownMenu> |
| 447 | } |
| 448 | > |
| 449 | <ul> |
| 450 | {sidebar.spaces.map((space) => ( |
| 451 | <SpaceTree key={space.id} slug={slug} space={space} current={current} drag={drag} setDrag={setDrag} /> |
| 452 | ))} |
| 453 | {sidebar.spaces.length === 0 && <li className="px-2 py-1 text-xs text-faint">No spaces yet.</li>} |
| 454 | </ul> |
| 455 | </Section> |
| 456 | <Section |
| 457 | title="Private" |
| 458 | action={ |
| 459 | <Hint label="New private doc"> |
| 460 | <Button type="button" onClick={() => make("doc")} aria-label="New private doc" variant="ghost" size="icon" className="size-6 rounded text-faint"> |
| 461 | <Plus size={13} /> |
| 462 | </Button> |
| 463 | </Hint> |
| 464 | } |
| 465 | > |
| 466 | <div |
| 467 | onDragOver={(e) => { |
| 468 | if (drag) e.preventDefault(); |
| 469 | }} |
| 470 | onDrop={async (e) => { |
| 471 | // Dropped on Private's empty space: to its top, at the end. |
| 472 | if (e.target !== e.currentTarget || !drag) return; |
| 473 | e.preventDefault(); |
| 474 | const id = drag.id; |
| 475 | setDrag(null); |
| 476 | const moved = await foliosRequest(slug, "move", { folio_id: id, move: { space_id: null, parent_id: null, before_id: null } }); |
| 477 | if (moved.ok) void revalidate(); |
| 478 | else setError(moved.error.message); |
| 479 | }} |
| 480 | > |
| 481 | <Tree slug={slug} place={{ space_id: null, key: "private", editable: true }} nodes={sidebar.private_tree} current={current} drag={drag} setDrag={setDrag} empty="Only you can see what's here." /> |
| 482 | </div> |
| 483 | </Section> |
| 484 | {sidebar.shared.length > 0 && ( |
| 485 | <Section title="Shared"> |
| 486 | <ul className="space-y-px"> |
| 487 | {sidebar.shared.map((f) => ( |
| 488 | <RefRow key={f.id} folio={f} /> |
| 489 | ))} |
| 490 | </ul> |
| 491 | </Section> |
| 492 | )} |
| 493 | <div className="mt-4 space-y-px"> |
| 494 | {!!sidebar.stale_count && ( |
| 495 | <SideLink to={`/${slug}/-/artifacts/stale`} icon={<AlertTriangle size={15} className="text-warn" />} trailing={<span className="text-[0.6875rem] text-warn tabular-nums">{sidebar.stale_count}</span>}> |
| 496 | Possibly out of date |
| 497 | </SideLink> |
| 498 | )} |
| 499 | </div> |
| 500 | <Section |
| 501 | title="Projects' docs" |
| 502 | open={(sidebar.repos ?? []).length > 0} |
| 503 | action={ |
| 504 | <Hint label="Show a project's docs"> |
| 505 | <Button type="button" onClick={() => setAddingRepo(true)} aria-label="Show a project's docs" variant="ghost" size="icon" className="size-6 rounded text-faint"> |
| 506 | <Plus size={13} /> |
| 507 | </Button> |
| 508 | </Hint> |
| 509 | } |
| 510 | > |
| 511 | <ul> |
| 512 | {(sidebar.repos ?? []).map((space) => ( |
| 513 | <RepoTree key={space.id} slug={slug} space={space} /> |
| 514 | ))} |
| 515 | {(sidebar.repos ?? []).length === 0 && ( |
| 516 | <li> |
| 517 | <button type="button" onClick={() => setAddingRepo(true)} className="px-2 py-1 text-left text-xs text-faint hover:text-fg"> |
| 518 | Show a repository's docs folder here, read-only. |
| 519 | </button> |
| 520 | </li> |
| 521 | )} |
| 522 | </ul> |
| 523 | </Section> |
| 524 | <div className="mt-4 space-y-px"> |
| 525 | <SideLink to={`/${slug}/-/artifacts/trash`} icon={<Trash2 size={15} />} trailing={sidebar.trash_count ? <span className="text-[0.6875rem] text-faint tabular-nums">{sidebar.trash_count}</span> : undefined}> |
| 526 | Trash |
| 527 | </SideLink> |
| 528 | </div> |
| 529 | <RepoDocsDialog slug={slug} open={addingRepo} onOpenChange={setAddingRepo} shown={(sidebar.repos ?? []).map((r) => r.repo.toLowerCase())} onAdded={() => void revalidate()} /> |
| 530 | </> |
| 531 | )} |
| 532 | </nav> |
| 533 | </div> |
| 534 | ); |
| 535 | } |