Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Artifacts is a mode in the rail, the shell and the phone's tab bar in place of Docs: its home lists everything you can open by day or as cards under All, Yours and Shared with you, with search, filters and tiles to start a doc (slides, designs and dashboards say they are coming soon), and its sidebar holds favorites, spaces, Private, Shared, projects' docs and the trash. | 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"> | |
| g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them. | 366 | <div className="flex h-9 shrink-0 items-center gap-1 pr-1 pl-3"> |
| 367 | <h2 className="min-w-0 grow truncate text-xs font-medium text-faint">Artifacts</h2> | |
| Artifacts is a mode in the rail, the shell and the phone's tab bar in place of Docs: its home lists everything you can open by day or as cards under All, Yours and Shared with you, with search, filters and tiles to start a doc (slides, designs and dashboards say they are coming soon), and its sidebar holds favorites, spaces, Private, Shared, projects' docs and the trash. | 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 | } |