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.

199 linesCodeBlameRaw
1import type { DocSpace } from "@g1t/contracts";
2import { Archive, Check, Plus, RotateCcw, Trash2 } from "lucide-react";
3import { useState } from "react";
4import { Link, data } from "react-router";
5
6import type { Route } from "./+types/spaces";
7import { useFoliosAction, useFoliosData } from "../../../components/folios/actions";
8import { SpaceIcon, spaceKindLabel } from "../../../components/folios/parts";
9import { EmptyState, ErrorText, TimeAgo } from "../../../components/ui";
10import {
11 AlertDialog,
12 AlertDialogAction,
13 AlertDialogCancel,
14 AlertDialogContent,
15 AlertDialogDescription,
16 AlertDialogFooter,
17 AlertDialogHeader,
18 AlertDialogTitle,
19} from "../../../components/ui/alert-dialog";
20import { Button } from "../../../components/ui/button";
21import { Card } from "../../../components/ui/card";
22import { canDo, spacePath } from "../../../lib/folios";
23import { page } from "../../../lib/meta";
24import { docs } from "../../../lib/services.server";
25import { requireUser, roleIn } from "../../../lib/session.server";
26
27export function meta({ params, ...args }: Route.MetaArgs) {
28 return page(args, { title: `Spaces · Artifacts · ${params.owner} · g1t` });
29}
30
31export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ spaces: DocSpace[] | null }> {
32 const viewer = requireUser(context, request);
33 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
34 const found = await docs.spaces(params.owner.toLowerCase(), viewer).catch(() => null);
35 return { spaces: found?.ok ? found.value : null };
36}
37
38/**
39 * Every space the viewer can open. Open spaces are joined to show in the
40 * sidebar, so it doesn't fill up; team and members-only spaces show
41 * there already. Archived spaces are listed apart at the bottom, where
42 * they are brought back or deleted.
43 */
44export default function BrowseSpaces({ loaderData, params }: Route.ComponentProps) {
45 const slug = params.owner.toLowerCase();
46 const layout = useFoliosData();
47 const { send, busy, error } = useFoliosAction(slug);
48 const shown = new Set((layout?.sidebar?.spaces ?? []).map((s) => s.id));
49 const owner = layout?.role === "owner";
50 const [deleting, setDeleting] = useState<DocSpace | null>(null);
51 const spaces = loaderData.spaces;
52 const live = (spaces ?? []).filter((s) => !s.archived_at);
53 const archived = (spaces ?? []).filter((s) => s.archived_at);
54 return (
55 <div className="mx-auto max-w-4xl">
56 <div className="flex flex-wrap items-end justify-between gap-4">
57 <div>
58 <h1 className="text-xl font-semibold tracking-tight">Spaces</h1>
59 <p className="mt-1 text-sm text-muted">A space holds artifacts for a team, a project or a topic. Join an open space to see it in your sidebar.</p>
60 </div>
61 <Button asChild variant="outline">
62 <Link to={`/${slug}/-/artifacts/spaces/new`}>
63 <Plus size={15} /> New space
64 </Link>
65 </Button>
66 </div>
67 {error && (
68 <div className="mt-3">
69 <ErrorText>{error}</ErrorText>
70 </div>
71 )}
72 <div className="mt-6">
73 {spaces === null ? (
74 <EmptyState title="Artifacts didn't answer">Try again in a moment.</EmptyState>
75 ) : live.length === 0 ? (
76 <EmptyState title="No spaces yet">Make one for a team, a project or a topic. Artifacts in no space live in their owners&apos; Private.</EmptyState>
77 ) : (
78 <Card asChild divided className="overflow-hidden">
79 <ul>
80 {live.map((s) => {
81 const joined = shown.has(s.id);
82 const joinable = s.kind === "workspace" && !s.is_default;
83 return (
84 <li key={s.id} className="flex items-center gap-3 px-4 py-3">
85 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-raised text-base">
86 <SpaceIcon space={s} size={16} />
87 </span>
88 <span className="min-w-0 grow">
89 <Link to={spacePath(slug, s.slug)} className="block truncate text-sm font-medium text-fg hover:text-accent">
90 {s.name}
91 </Link>
92 <span className="block truncate text-xs text-faint">
93 {spaceKindLabel(s.kind)}
94 {s.description ? ` · ${s.description}` : ""}
95 </span>
96 </span>
97 {joinable ? (
98 <Button
99 type="button"
100 size="sm"
101 variant={joined ? "outline" : "accent"}
102 disabled={busy}
103 onClick={() => send(joined ? "leave_space" : "join_space", { space_id: s.id })}
104 className="shrink-0"
105 >
106 {joined ? "Leave" : "Join"}
107 </Button>
108 ) : (
109 <span className="inline-flex shrink-0 items-center gap-1 text-xs text-faint">
110 <Check size={13} /> In your sidebar
111 </span>
112 )}
113 </li>
114 );
115 })}
116 </ul>
117 </Card>
118 )}
119 </div>
120 {archived.length > 0 && (
121 <section className="mt-10">
122 <h2 className="flex items-center gap-2 text-sm font-semibold">
123 <Archive size={14} className="text-faint" /> Archived
124 </h2>
125 <p className="mt-1 mb-4 text-xs leading-relaxed text-faint">
126 Out of the sidebar and search, with everything in them kept. Bring one back, or delete it: its artifacts go to the trash.
127 </p>
128 <Card asChild divided className="overflow-hidden">
129 <ul>
130 {archived.map((s) => {
131 const manages = canDo(s.viewer_role, "manage") || owner;
132 return (
133 <li key={s.id} className="flex items-center gap-3 px-4 py-3">
134 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-raised text-base opacity-70">
135 <SpaceIcon space={s} size={16} />
136 </span>
137 <span className="min-w-0 grow">
138 <span className="block truncate text-sm font-medium text-fg">{s.name}</span>
139 <span className="block truncate text-xs text-faint">
140 {spaceKindLabel(s.kind)} · archived {s.archived_at ? <TimeAgo at={s.archived_at} /> : null}
141 </span>
142 </span>
143 {manages && (
144 <>
145 <Button
146 type="button"
147 size="sm"
148 variant="outline"
149 disabled={busy}
150 onClick={() => send("update_space", { space_id: s.id, space_change: { archived: false } })}
151 className="shrink-0 text-xs font-normal"
152 >
153 <RotateCcw size={13} /> Restore
154 </Button>
155 <Button
156 type="button"
157 size="sm"
158 variant="destructive"
159 disabled={busy}
160 onClick={() => setDeleting(s)}
161 className="shrink-0 text-xs font-normal"
162 >
163 <Trash2 size={13} /> <span className="max-sm:hidden">Delete</span>
164 </Button>
165 </>
166 )}
167 </li>
168 );
169 })}
170 </ul>
171 </Card>
172 </section>
173 )}
174 <AlertDialog open={!!deleting} onOpenChange={(open) => !open && setDeleting(null)}>
175 <AlertDialogContent>
176 <AlertDialogHeader>
177 <AlertDialogTitle>Delete {deleting?.name}?</AlertDialogTitle>
178 <AlertDialogDescription>
179 Everything in it goes to the trash, where anyone who could edit it can restore it for 30 days. The space itself, its members and settings are gone
180 for good.
181 {deleting?.is_default ? " The workspace will have no General space; none takes its place." : ""}
182 </AlertDialogDescription>
183 </AlertDialogHeader>
184 <AlertDialogFooter>
185 <AlertDialogCancel>Keep it</AlertDialogCancel>
186 <AlertDialogAction
187 onClick={() => {
188 if (deleting) void send("delete_space", { space_id: deleting.id });
189 setDeleting(null);
190 }}
191 >
192 Delete space
193 </AlertDialogAction>
194 </AlertDialogFooter>
195 </AlertDialogContent>
196 </AlertDialog>
197 </div>
198 );
199}