Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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