| 1 | import { FOLIO_KINDS, isFolioKind, type Folio, type FolioKind, type FolioList } from "@g1t/contracts"; |
| 2 | import { ChevronDown, LayoutGrid, LayoutTemplate, List, Search, SlidersHorizontal, Users } from "lucide-react"; |
| 3 | import { type ReactNode, useEffect, useRef, useState } from "react"; |
| 4 | import { Form, Link, data, useNavigation, useSearchParams } from "react-router"; |
| 5 | |
| 6 | import type { Route } from "./+types/home"; |
| 7 | |
| 8 | import { foliosQuery, useFoliosData, useViewerZone } from "../../../components/folios/actions"; |
| 9 | import { FOLIO_KIND_UI, KindIcon } from "../../../components/folios/kinds"; |
| 10 | import { FolioDays, FolioGrid } from "../../../components/folios/list"; |
| 11 | import { FoliosSidebar } from "../../../components/folios/sidebar"; |
| 12 | import { BottomSheet } from "../../../components/mobile"; |
| 13 | import { EmptyState, ErrorText } from "../../../components/ui"; |
| 14 | import { Button } from "../../../components/ui/button"; |
| 15 | import { Card } from "../../../components/ui/card"; |
| 16 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../../../components/ui/dialog"; |
| 17 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../../components/ui/dropdown-menu"; |
| 18 | import { Hint } from "../../../components/ui/hint"; |
| 19 | import { SelectField } from "../../../components/ui/select"; |
| 20 | import { TabStrip } from "../../../components/ui/tab-strip"; |
| 21 | import { canDo, listQuery } from "../../../lib/folios"; |
| 22 | import { page } from "../../../lib/meta"; |
| 23 | import { folios, identity, repos } from "../../../lib/services.server"; |
| 24 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 25 | |
| 26 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 27 | return page(args, { title: `Artifacts · ${params.owner} · g1t` }); |
| 28 | } |
| 29 | |
| 30 | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ list: FolioList | null; projects: string[] }> { |
| 31 | const viewer = requireUser(context, request); |
| 32 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); |
| 33 | const slug = params.owner.toLowerCase(); |
| 34 | const search = new URL(request.url).searchParams; |
| 35 | const query = listQuery(search); |
| 36 | // The owner filter is a username in the address; the service keys people by id. |
| 37 | const ownerName = search.get("owner"); |
| 38 | if (ownerName) { |
| 39 | const user = await identity.userByUsername(ownerName.toLowerCase()).catch(() => null); |
| 40 | query.owner = user ? `user:${user.id}` : "user:nobody"; |
| 41 | } |
| 42 | const [list, projects] = await Promise.all([ |
| 43 | folios |
| 44 | .list(slug, viewer, query) |
| 45 | .then((r) => (r.ok ? r.value : null)) |
| 46 | .catch(() => null), |
| 47 | repos |
| 48 | .list(viewer, { namespace: slug }) |
| 49 | .then((found) => found.filter((r) => !r.forkOf).map((r) => `${r.namespace}/${r.name}`.toLowerCase())) |
| 50 | .catch(() => [] as string[]), |
| 51 | ]); |
| 52 | return { list, projects }; |
| 53 | } |
| 54 | |
| 55 | const TABS = [ |
| 56 | { key: "all", label: "All" }, |
| 57 | { key: "yours", label: "Yours" }, |
| 58 | { key: "shared", label: "Shared with you" }, |
| 59 | ] as const; |
| 60 | |
| 61 | const EMPTY: Record<string, { title: string; body: string }> = { |
| 62 | all: { title: "No artifacts yet", body: "Start a doc above. Everything you and your workspace make shows up here." }, |
| 63 | yours: { title: "Nothing of yours yet", body: "What you make is yours, in Private until you share it or move it to a space." }, |
| 64 | shared: { title: "Nothing shared with you yet", body: "When someone shares an artifact with you, it shows up here." }, |
| 65 | }; |
| 66 | |
| 67 | /** |
| 68 | * Artifacts' home (docs.g1t.sh/guides/artifacts/, "Home"): search and |
| 69 | * filters, All / Yours / Shared with you, tiles to make something new, |
| 70 | * and everything you can open, grouped by the day it was last edited, or |
| 71 | * as cards. |
| 72 | */ |
| 73 | export default function FoliosHome({ loaderData, params }: Route.ComponentProps) { |
| 74 | const slug = params.owner.toLowerCase(); |
| 75 | const { list, projects } = loaderData; |
| 76 | const layout = useFoliosData(); |
| 77 | const zone = useViewerZone(); |
| 78 | const [search, setSearch] = useSearchParams(); |
| 79 | const tab = search.get("tab") === "yours" || search.get("tab") === "shared" ? search.get("tab")! : "all"; |
| 80 | const view = search.get("view") === "grid" ? "grid" : "list"; |
| 81 | const q = search.get("q") ?? ""; |
| 82 | const filtered = ["kind", "space", "owner", "project"].some((k) => search.get(k)); |
| 83 | const [items, setItems] = useState<Folio[]>(list?.items ?? []); |
| 84 | const [cursor, setCursor] = useState<string | null>(list?.next_cursor ?? null); |
| 85 | const [more, setMore] = useState(false); |
| 86 | const [error, setError] = useState<string | null>(null); |
| 87 | const [filtersOpen, setFiltersOpen] = useState(false); |
| 88 | const searchBox = useRef<HTMLInputElement>(null); |
| 89 | useEffect(() => { |
| 90 | setItems(list?.items ?? []); |
| 91 | setCursor(list?.next_cursor ?? null); |
| 92 | }, [list]); |
| 93 | // `/` goes to the search box, as everywhere a page has one. |
| 94 | useEffect(() => { |
| 95 | const onKey = (e: KeyboardEvent) => { |
| 96 | const target = e.target as HTMLElement | null; |
| 97 | if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey || target?.closest("input, textarea, [contenteditable=true]")) return; |
| 98 | e.preventDefault(); |
| 99 | searchBox.current?.focus(); |
| 100 | }; |
| 101 | window.addEventListener("keydown", onKey); |
| 102 | return () => window.removeEventListener("keydown", onKey); |
| 103 | }, []); |
| 104 | const href = (changes: Record<string, string | null>) => { |
| 105 | const next = new URLSearchParams(search); |
| 106 | for (const [k, v] of Object.entries(changes)) { |
| 107 | if (v) next.set(k, v); |
| 108 | else next.delete(k); |
| 109 | } |
| 110 | next.delete("cursor"); |
| 111 | const s = next.toString(); |
| 112 | return s ? `?${s}` : "."; |
| 113 | }; |
| 114 | const set = (changes: Record<string, string | null>) => |
| 115 | setSearch( |
| 116 | (was) => { |
| 117 | const next = new URLSearchParams(was); |
| 118 | for (const [k, v] of Object.entries(changes)) { |
| 119 | if (v) next.set(k, v); |
| 120 | else next.delete(k); |
| 121 | } |
| 122 | next.delete("cursor"); |
| 123 | return next; |
| 124 | }, |
| 125 | { preventScrollReset: true }, |
| 126 | ); |
| 127 | const showMore = async () => { |
| 128 | if (!cursor) return; |
| 129 | setMore(true); |
| 130 | const next = new URLSearchParams(search); |
| 131 | next.set("list", "1"); |
| 132 | next.set("cursor", cursor); |
| 133 | const page = await foliosQuery<FolioList>(slug, Object.fromEntries(next)); |
| 134 | setMore(false); |
| 135 | if (!page.ok) return setError(page.error.message); |
| 136 | setItems((was) => [...was, ...page.value.items.filter((f) => !was.some((w) => w.id === f.id))]); |
| 137 | setCursor(page.value.next_cursor); |
| 138 | }; |
| 139 | const spaces = layout?.sidebar?.spaces ?? []; |
| 140 | const filters = ( |
| 141 | <> |
| 142 | <SelectField |
| 143 | aria-label="Kind" |
| 144 | value={search.get("kind") ?? "any"} |
| 145 | onValueChange={(v) => set({ kind: v === "any" ? null : v })} |
| 146 | options={[{ value: "any", label: "Any kind" }, ...FOLIO_KINDS.map((k) => ({ value: k, label: FOLIO_KIND_UI[k].label }))]} |
| 147 | className="h-9 w-36 max-md:w-full" |
| 148 | /> |
| 149 | <SelectField |
| 150 | aria-label="Space" |
| 151 | value={search.get("space") ?? "any"} |
| 152 | onValueChange={(v) => set({ space: v === "any" ? null : v })} |
| 153 | options={[{ value: "any", label: "Any space" }, { value: "private", label: "Private" }, ...spaces.map((s) => ({ value: s.id, label: s.name }))]} |
| 154 | className="h-9 w-36 max-md:w-full" |
| 155 | /> |
| 156 | <SelectField |
| 157 | aria-label="Owner" |
| 158 | value={search.get("owner") ?? "any"} |
| 159 | onValueChange={(v) => set({ owner: v === "any" ? null : v })} |
| 160 | options={[{ value: "any", label: "Anyone" }, ...(layout?.mentionables ?? []).filter((m) => m.kind === "user").map((m) => ({ value: m.name, label: m.name === layout?.me.name ? "You" : m.display_name }))]} |
| 161 | className="h-9 w-36 max-md:w-full" |
| 162 | /> |
| 163 | {projects.length > 0 && ( |
| 164 | <SelectField |
| 165 | aria-label="Project" |
| 166 | value={search.get("project") ?? "any"} |
| 167 | onValueChange={(v) => set({ project: v === "any" ? null : v })} |
| 168 | options={[{ value: "any", label: "Any project" }, ...projects.map((p) => ({ value: p, label: p }))]} |
| 169 | className="h-9 w-44 max-md:w-full" |
| 170 | /> |
| 171 | )} |
| 172 | </> |
| 173 | ); |
| 174 | return ( |
| 175 | <div className="mx-auto max-w-5xl"> |
| 176 | <div className="flex flex-wrap items-center gap-3"> |
| 177 | <h1 className="grow text-2xl font-semibold tracking-tight">Artifacts</h1> |
| 178 | <Hint label="Templates"> |
| 179 | <Link to={`/${slug}/-/artifacts/templates`} aria-label="Templates" className="flex size-9 items-center justify-center rounded-md border border-line text-muted transition-colors hover:border-line-strong hover:bg-surface hover:text-fg"> |
| 180 | <LayoutTemplate size={16} /> |
| 181 | </Link> |
| 182 | </Hint> |
| 183 | </div> |
| 184 | |
| 185 | <MakeTiles slug={slug} spaces={spaces.filter((s) => canDo(s.viewer_role, "edit")).map((s) => ({ id: s.id, name: s.name }))} /> |
| 186 | |
| 187 | <div className="mt-8 flex flex-wrap items-center gap-2"> |
| 188 | <Form method="get" className="flex h-9 min-w-0 grow items-center gap-2 rounded-md border border-line bg-surface px-3 focus-within:border-accent/60 sm:max-w-sm" onSubmit={(e) => (e.preventDefault(), set({ q: (new FormData(e.currentTarget).get("q") as string).trim() || null }))}> |
| 189 | <Search size={15} className="text-faint" aria-hidden="true" /> |
| 190 | <input ref={searchBox} key={q} name="q" defaultValue={q} placeholder="Search artifacts" aria-label="Search artifacts" className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint" /> |
| 191 | <kbd className="hidden rounded border border-line px-1.5 font-mono text-[0.625rem] text-faint sm:inline">/</kbd> |
| 192 | </Form> |
| 193 | <div className="hidden flex-wrap items-center gap-2 md:flex">{filters}</div> |
| 194 | <button type="button" onClick={() => setFiltersOpen(true)} className={`inline-flex h-9 items-center gap-1.5 rounded-md border px-3 text-sm md:hidden ${filtered ? "border-accent/50 text-accent" : "border-line text-muted"}`}> |
| 195 | <SlidersHorizontal size={14} /> Filters |
| 196 | </button> |
| 197 | <div className="ml-auto hidden items-center rounded-md border border-line p-0.5 md:flex" role="group" aria-label="Show as"> |
| 198 | <ViewButton active={view === "list"} label="List" to={href({ view: null })}> |
| 199 | <List size={15} /> |
| 200 | </ViewButton> |
| 201 | <ViewButton active={view === "grid"} label="Grid" to={href({ view: "grid" })}> |
| 202 | <LayoutGrid size={15} /> |
| 203 | </ViewButton> |
| 204 | </div> |
| 205 | </div> |
| 206 | <BottomSheet open={filtersOpen} onOpenChange={setFiltersOpen} title="Filters"> |
| 207 | <div className="flex flex-col gap-2 px-3 pb-4 [&>*]:w-full">{filters}</div> |
| 208 | </BottomSheet> |
| 209 | |
| 210 | <TabStrip label="Which artifacts" className="mt-5 gap-1 border-b border-line"> |
| 211 | {TABS.map((t) => ( |
| 212 | <Link |
| 213 | key={t.key} |
| 214 | to={href({ tab: t.key === "all" ? null : t.key })} |
| 215 | preventScrollReset |
| 216 | data-active={tab === t.key} |
| 217 | aria-current={tab === t.key ? "page" : undefined} |
| 218 | className={`-mb-px flex items-center gap-2 border-b-2 px-3 pb-3 text-sm whitespace-nowrap transition-colors ${tab === t.key ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg"}`} |
| 219 | > |
| 220 | {t.key === "shared" && <Users size={14} />} |
| 221 | {t.label} |
| 222 | </Link> |
| 223 | ))} |
| 224 | </TabStrip> |
| 225 | |
| 226 | {error && ( |
| 227 | <div className="mt-3"> |
| 228 | <ErrorText>{error}</ErrorText> |
| 229 | </div> |
| 230 | )} |
| 231 | |
| 232 | <div className="mt-5 mb-8"> |
| 233 | {!list ? ( |
| 234 | <EmptyState title="Artifacts didn't answer">Try again in a moment.</EmptyState> |
| 235 | ) : items.length === 0 ? ( |
| 236 | q || filtered ? ( |
| 237 | <EmptyState title="Nothing matches"> |
| 238 | <Link to={href({ q: null, kind: null, space: null, owner: null, project: null })} className="text-accent hover:underline"> |
| 239 | Clear the search and filters |
| 240 | </Link> |
| 241 | </EmptyState> |
| 242 | ) : ( |
| 243 | <EmptyState title={EMPTY[tab]!.title}>{EMPTY[tab]!.body}</EmptyState> |
| 244 | ) |
| 245 | ) : view === "grid" ? ( |
| 246 | <> |
| 247 | <div className="hidden md:block"> |
| 248 | <FolioGrid slug={slug} items={items} onError={setError} /> |
| 249 | </div> |
| 250 | <div className="md:hidden"> |
| 251 | <FolioDays slug={slug} items={items} zone={zone} onError={setError} /> |
| 252 | </div> |
| 253 | </> |
| 254 | ) : ( |
| 255 | <FolioDays slug={slug} items={items} zone={zone} onError={setError} /> |
| 256 | )} |
| 257 | {cursor && ( |
| 258 | <div className="mt-4 flex justify-center"> |
| 259 | <Button type="button" onClick={showMore} disabled={more} variant="outline" className="px-4 text-muted disabled:opacity-60 font-normal"> |
| 260 | {more ? "Loading…" : "Show more"} |
| 261 | </Button> |
| 262 | </div> |
| 263 | )} |
| 264 | </div> |
| 265 | |
| 266 | {/* On a phone, the sidebar's places below the list: the mode's sheet has them too. */} |
| 267 | <div className="-mx-4 border-t border-line md:hidden"> |
| 268 | <div className="h-[60dvh]"> |
| 269 | <FoliosSidebar slug={slug} /> |
| 270 | </div> |
| 271 | </div> |
| 272 | </div> |
| 273 | ); |
| 274 | } |
| 275 | |
| 276 | function ViewButton({ active, label, to, children }: { active: boolean; label: string; to: string; children: ReactNode }) { |
| 277 | return ( |
| 278 | <Hint label={label}> |
| 279 | <Link to={to} preventScrollReset aria-label={label} aria-pressed={active} className={`flex size-7 items-center justify-center rounded ${active ? "bg-raised text-fg" : "text-faint hover:text-fg"}`}> |
| 280 | {children} |
| 281 | </Link> |
| 282 | </Hint> |
| 283 | ); |
| 284 | } |
| 285 | |
| 286 | /** |
| 287 | * "Make something new": a tile per kind. Docs makes one at once (a form |
| 288 | * post, so it can't go twice), its chevron starts from a template or in a |
| 289 | * space; the kinds still to come say so. |
| 290 | */ |
| 291 | function MakeTiles({ slug, spaces }: { slug: string; spaces: { id: string; name: string }[] }) { |
| 292 | const navigation = useNavigation(); |
| 293 | const starting = navigation.state !== "idle" && navigation.formMethod?.toUpperCase() === "POST" && navigation.formAction?.includes("/-/artifacts/new/"); |
| 294 | const [choosing, setChoosing] = useState(false); |
| 295 | return ( |
| 296 | <div className="-mx-4 mt-5 flex snap-x gap-3 overflow-x-auto px-4 pb-1 [scrollbar-width:none] sm:mx-0 sm:grid sm:grid-cols-4 sm:overflow-visible sm:px-0"> |
| 297 | {FOLIO_KINDS.map((kind) => { |
| 298 | const ui = FOLIO_KIND_UI[kind]; |
| 299 | const body = ( |
| 300 | <> |
| 301 | <KindIcon kind={kind} size={18} box={36} /> |
| 302 | <span className="mt-3 flex items-center gap-1.5 text-sm font-medium text-fg"> |
| 303 | {ui.label} |
| 304 | {ui.beta && <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">Beta</span>} |
| 305 | </span> |
| 306 | <span className="mt-0.5 text-xs text-faint">{ui.ready ? (starting ? "Starting…" : tileLine(kind)) : "Coming soon"}</span> |
| 307 | </> |
| 308 | ); |
| 309 | if (!ui.ready) { |
| 310 | return ( |
| 311 | <Hint key={kind} label={`${COMING[kind]} are coming soon.`}> |
| 312 | <Card tabIndex={0} aria-disabled="true" tone="plain" className="flex w-40 shrink-0 snap-start flex-col border-dashed bg-surface/40 p-4 opacity-60 sm:w-auto"> |
| 313 | {body} |
| 314 | </Card> |
| 315 | </Hint> |
| 316 | ); |
| 317 | } |
| 318 | return ( |
| 319 | <div key={kind} className="relative flex w-40 shrink-0 snap-start sm:w-auto"> |
| 320 | <Form method="post" action={`/${slug}/-/artifacts/new/${kind}`} className="flex grow"> |
| 321 | <button type="submit" disabled={starting} aria-busy={starting} className="flex grow flex-col rounded-xl border border-line bg-surface p-4 text-left transition-colors hover:border-accent/50 hover:bg-raised/40 disabled:opacity-70"> |
| 322 | {body} |
| 323 | </button> |
| 324 | </Form> |
| 325 | <DropdownMenu> |
| 326 | <DropdownMenuTrigger asChild> |
| 327 | <Button type="button" aria-label={`More ways to make ${ui.label.toLowerCase()}`} variant="ghost" size="icon-xs" className="absolute top-2 right-2 text-faint"> |
| 328 | <ChevronDown size={14} /> |
| 329 | </Button> |
| 330 | </DropdownMenuTrigger> |
| 331 | <DropdownMenuContent align="end" className="w-48"> |
| 332 | <DropdownMenuItem asChild> |
| 333 | <Link to={`/${slug}/-/artifacts/templates?kind=${kind}`}>From a template…</Link> |
| 334 | </DropdownMenuItem> |
| 335 | <DropdownMenuItem disabled={spaces.length === 0} onSelect={() => setChoosing(true)}> |
| 336 | In a space… |
| 337 | </DropdownMenuItem> |
| 338 | </DropdownMenuContent> |
| 339 | </DropdownMenu> |
| 340 | <SpaceChooser slug={slug} kind={kind} spaces={spaces} open={choosing} onOpenChange={setChoosing} /> |
| 341 | </div> |
| 342 | ); |
| 343 | })} |
| 344 | </div> |
| 345 | ); |
| 346 | } |
| 347 | |
| 348 | /** What each kind is for, under its tile. */ |
| 349 | const TILE_LINES: Record<FolioKind, string> = { doc: "Notes, specs, decisions", slides: "Decks to present", design: "Screens and layouts", dashboard: "Numbers to track" }; |
| 350 | |
| 351 | /** The kinds still to come, as the Hint names them. */ |
| 352 | const COMING: Record<FolioKind, string> = { doc: "Docs", slides: "Slides", design: "Designs", dashboard: "Dashboards" }; |
| 353 | |
| 354 | function tileLine(kind: FolioKind): string { |
| 355 | return TILE_LINES[kind]; |
| 356 | } |
| 357 | |
| 358 | function SpaceChooser({ slug, kind, spaces, open, onOpenChange }: { slug: string; kind: FolioKind; spaces: { id: string; name: string }[]; open: boolean; onOpenChange: (o: boolean) => void }) { |
| 359 | const [space, setSpace] = useState(spaces[0]?.id ?? ""); |
| 360 | const navigation = useNavigation(); |
| 361 | const busy = navigation.state !== "idle"; |
| 362 | if (!isFolioKind(kind)) return null; |
| 363 | return ( |
| 364 | <Dialog open={open} onOpenChange={onOpenChange}> |
| 365 | <DialogContent> |
| 366 | <DialogHeader> |
| 367 | <DialogTitle>New {FOLIO_KIND_UI[kind].label.toLowerCase().replace(/s$/, "")} in a space</DialogTitle> |
| 368 | <DialogDescription>It follows the space's access: everyone in the space can open it.</DialogDescription> |
| 369 | </DialogHeader> |
| 370 | <Form method="post" action={`/${slug}/-/artifacts/new/${kind}`} className="space-y-4"> |
| 371 | <SelectField name="space" aria-label="Space" value={space} onValueChange={setSpace} options={spaces.map((s) => ({ value: s.id, label: s.name }))} className="h-9 w-full" /> |
| 372 | <div className="flex justify-end"> |
| 373 | <Button type="submit" disabled={busy || !space} variant="accent" className="disabled:opacity-60"> |
| 374 | {busy ? "Starting…" : "Make it"} |
| 375 | </Button> |
| 376 | </div> |
| 377 | </Form> |
| 378 | </DialogContent> |
| 379 | </Dialog> |
| 380 | ); |
| 381 | } |