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