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.

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