| 1 | import { DOC_ROLE_LABELS, type DocSpace, type FolioList } from "@g1t/contracts"; |
| 2 | import { FilePlus2, Settings } from "lucide-react"; |
| 3 | import { useState } from "react"; |
| 4 | import { Form, Link, data, useNavigation } from "react-router"; |
| 5 | |
| 6 | import type { Route } from "./+types/space"; |
| 7 | import { useFoliosAction, useFoliosData, useViewerZone } from "../../../components/folios/actions"; |
| 8 | import { FolioDays } from "../../../components/folios/list"; |
| 9 | import { SpaceIcon, spaceKindLabel } from "../../../components/folios/parts"; |
| 10 | import { EmptyState, ErrorText } from "../../../components/ui"; |
| 11 | import { Hint } from "../../../components/ui/hint"; |
| 12 | import { canDo, spacePath } from "../../../lib/folios"; |
| 13 | import { page } from "../../../lib/meta"; |
| 14 | import { docs, folios } from "../../../lib/services.server"; |
| 15 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 16 | |
| 17 | export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) { |
| 18 | return page(args, { title: `${loaded?.space.name ?? "Space"} · Artifacts · ${params.owner} · g1t`, description: loaded?.space.description ?? undefined }); |
| 19 | } |
| 20 | |
| 21 | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ space: DocSpace; list: FolioList | null }> { |
| 22 | const viewer = requireUser(context, request); |
| 23 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); |
| 24 | const slug = params.owner.toLowerCase(); |
| 25 | const found = await docs.space(slug, params.space, viewer).catch(() => null); |
| 26 | if (!found?.ok || found.value.space.archived_at) throw data(null, { status: 404 }); |
| 27 | const list = await folios |
| 28 | .list(slug, viewer, { tab: "all", space_id: found.value.space.id, limit: 100 }) |
| 29 | .then((r) => (r.ok ? r.value : null)) |
| 30 | .catch(() => null); |
| 31 | return { space: found.value.space, list }; |
| 32 | } |
| 33 | |
| 34 | /** A space: who it's for, joining it (an open one), making a doc in it, and everything in it the viewer can open, by day. */ |
| 35 | export default function SpacePage({ loaderData, params }: Route.ComponentProps) { |
| 36 | const slug = params.owner.toLowerCase(); |
| 37 | const { space, list } = loaderData; |
| 38 | const layout = useFoliosData(); |
| 39 | const zone = useViewerZone(); |
| 40 | const { send, busy, error } = useFoliosAction(slug); |
| 41 | const [failed, setFailed] = useState<string | null>(null); |
| 42 | const starting = useNavigation().state !== "idle"; |
| 43 | const joined = (layout?.sidebar?.spaces ?? []).some((s) => s.id === space.id); |
| 44 | const joinable = space.kind === "workspace" && !space.is_default; |
| 45 | const who = space.kind === "workspace" ? `Everyone in the workspace (${space.default_role ? DOC_ROLE_LABELS[space.default_role].toLowerCase() : "listed members only"})` : space.kind === "team" ? `The ${space.team} team, and members added to it` : "Only its members"; |
| 46 | return ( |
| 47 | <div className="mx-auto max-w-5xl"> |
| 48 | <div className="flex flex-wrap items-start gap-4"> |
| 49 | <span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-raised text-2xl"> |
| 50 | <SpaceIcon space={space} size={22} /> |
| 51 | </span> |
| 52 | <div className="min-w-0 grow"> |
| 53 | <h1 className="truncate text-2xl font-semibold tracking-tight">{space.name}</h1> |
| 54 | <p className="mt-1 text-sm text-muted">{space.description || "No description."}</p> |
| 55 | <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-faint"> |
| 56 | <span className="rounded-full px-2 py-0.5 ring-1 ring-line">{spaceKindLabel(space.kind)}</span> |
| 57 | <span>{who}</span> |
| 58 | <span>Agents here: {space.agent_mode === "edit" ? "edit directly" : "suggest changes"}</span> |
| 59 | </p> |
| 60 | </div> |
| 61 | <div className="flex items-center gap-2"> |
| 62 | {joinable && ( |
| 63 | <button type="button" disabled={busy} onClick={() => send(joined ? "leave_space" : "join_space", { space_id: space.id })} className="inline-flex h-9 items-center rounded-md border border-line px-3 text-sm text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg disabled:opacity-60"> |
| 64 | {joined ? "Leave" : "Join"} |
| 65 | </button> |
| 66 | )} |
| 67 | {canDo(space.viewer_role, "manage") && ( |
| 68 | <Hint label="Space settings"> |
| 69 | <Link to={`${spacePath(slug, space.slug)}/settings`} aria-label="Space settings" className="flex size-9 items-center justify-center rounded-md border border-line text-muted hover:border-line-strong hover:bg-surface hover:text-fg"> |
| 70 | <Settings size={16} /> |
| 71 | </Link> |
| 72 | </Hint> |
| 73 | )} |
| 74 | {canDo(space.viewer_role, "edit") && ( |
| 75 | <Form method="post" action={`/${slug}/-/artifacts/new/doc`}> |
| 76 | <input type="hidden" name="space" value={space.id} /> |
| 77 | <button type="submit" disabled={starting} className="inline-flex h-9 items-center gap-1.5 rounded-md bg-accent px-3 text-sm font-medium text-bg hover:bg-accent-hover disabled:opacity-60"> |
| 78 | <FilePlus2 size={15} /> {starting ? "Starting…" : "New doc"} |
| 79 | </button> |
| 80 | </Form> |
| 81 | )} |
| 82 | </div> |
| 83 | </div> |
| 84 | {(error || failed) && ( |
| 85 | <div className="mt-3"> |
| 86 | <ErrorText>{error ?? failed}</ErrorText> |
| 87 | </div> |
| 88 | )} |
| 89 | <div className="mt-8 mb-8"> |
| 90 | {!list ? ( |
| 91 | <EmptyState title="Artifacts didn't answer">Try again in a moment.</EmptyState> |
| 92 | ) : list.items.length === 0 ? ( |
| 93 | <EmptyState title="Nothing here yet">{canDo(space.viewer_role, "edit") ? "Start a doc with New doc, or move something here from its ⋯ menu." : "When someone adds an artifact here, it shows up for you."}</EmptyState> |
| 94 | ) : ( |
| 95 | <FolioDays slug={slug} items={list.items} zone={zone} onError={setFailed} /> |
| 96 | )} |
| 97 | </div> |
| 98 | </div> |
| 99 | ); |
| 100 | } |