Skip to content
188 linesCodeBlameRaw
1import { DOC_ROLES, DOC_ROLE_LABELS, DOC_ROLE_SUMMARIES, type DocRole, type DocSpace, type DocSpaceMember } from "@g1t/contracts";
2import { Archive, Share2 } from "lucide-react";
3import { useState } from "react";
4import { Link, data, useNavigate } from "react-router";
5
6import type { Route } from "./+types/space-settings";
7import { memberKey } from "../../../components/folios/share-dialog";
8import { useFoliosAction, useFoliosData } from "../../../components/folios/actions";
9import { Face, SectionTitle } from "../../../components/folios/parts";
10import { SpaceForm, initialSpace, spaceInput } from "../../../components/folios/space-form";
11import { ErrorText } from "../../../components/ui";
12import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../../components/ui/alert-dialog";
13import { Button } from "../../../components/ui/button";
14import { Card } from "../../../components/ui/card";
15import { SelectField } from "../../../components/ui/select";
16import { Switch } from "../../../components/ui/switch";
17import { spacePath } from "../../../lib/folios";
18import { page } from "../../../lib/meta";
19import { docs, identity } from "../../../lib/services.server";
20import { requireUser, roleIn } from "../../../lib/session.server";
21
22export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
23 return page(args, { title: `Settings · ${loaded?.space.name ?? "Space"} · Artifacts · ${params.owner} · g1t` });
24}
25
26export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ space: DocSpace; members: DocSpaceMember[]; teams: { slug: string; name: string }[] }> {
27 const viewer = requireUser(context, request);
28 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
29 const slug = params.owner.toLowerCase();
30 const [found, teams] = await Promise.all([docs.space(slug, params.space, viewer), identity.listTeams(viewer, slug).catch(() => null)]);
31 if (!found.ok) throw data(null, { status: 404 });
32 if (found.value.space.viewer_role !== "manage") throw data(null, { status: 403 });
33 return { space: found.value.space, members: found.value.members, teams: teams?.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] };
34}
35
36const ROLE_OPTIONS = DOC_ROLES.map((r) => ({ value: r, label: DOC_ROLE_LABELS[r], description: DOC_ROLE_SUMMARIES[r] }));
37
38/** A space's settings: what it is, who it's for, its members and their roles, whether editors can share, and how agents work in it. */
39export default function SpaceSettings({ loaderData, params }: Route.ComponentProps) {
40 const slug = params.owner.toLowerCase();
41 const { space, members, teams } = loaderData;
42 const layout = useFoliosData();
43 const navigate = useNavigate();
44 const settings = useFoliosAction(slug);
45 const people = useFoliosAction(slug);
46 const sharing = useFoliosAction(slug);
47 const [adding, setAdding] = useState("");
48 const [addRole, setAddRole] = useState<DocRole>("edit");
49 const [archiving, setArchiving] = useState(false);
50 const listed = new Set(members.map((m) => m.key));
51 const candidates = [
52 ...(layout?.mentionables ?? []).map((m) => ({ value: m.kind === "agent" ? `agent:${m.id}` : `username:${m.name}`, label: m.kind === "agent" ? `${m.display_name} (agent)` : `${m.display_name} (@${m.name})` })),
53 ...teams.map((t) => ({ value: `team:${t.slug}`, label: `${t.name} (team)` })),
54 ].filter((c) => !listed.has(c.value));
55 return (
56 <div className="mx-auto max-w-3xl">
57 <p className="text-xs text-faint">
58 <Link to={spacePath(slug, space.slug)} className="hover:text-fg">
59 {space.name}
60 </Link>{" "}
61 / Settings
62 </p>
63 <h1 className="mt-1 text-xl font-semibold tracking-tight">Space settings</h1>
64 <div className="mt-8">
65 <SpaceForm
66 initial={initialSpace(space)}
67 teams={teams}
68 isDefault={space.is_default}
69 submitLabel="Save"
70 busy={settings.busy}
71 error={settings.error}
72 onSubmit={async (value) => {
73 const saved = await settings.send<DocSpace>("update_space", { space_id: space.id, space_change: spaceInput(value) });
74 if (saved.ok && saved.value.slug !== space.slug) navigate(`${spacePath(slug, saved.value.slug)}/settings`, { replace: true });
75 }}
76 />
77 </div>
78
79 <section className="mt-12">
80 <SectionTitle>Sharing</SectionTitle>
81 <Card asChild className="flex items-start gap-3 px-4 py-3">
82 <label>
83 <Share2 size={16} className="mt-0.5 shrink-0 text-faint" />
84 <span className="min-w-0 grow">
85 <span className="block text-sm text-fg">Editors can share</span>
86 <span className="mt-0.5 block text-xs leading-relaxed text-faint">People who can edit what's in this space can also share it with others, up to Can edit. Off: only people with full access can share.</span>
87 </span>
88 <Switch
89 checked={space.editors_can_share}
90 disabled={sharing.busy}
91 onCheckedChange={(on) => sharing.send("update_space", { space_id: space.id, space_change: { editors_can_share: on } })}
92 aria-label="Editors can share"
93 />
94 </label>
95 </Card>
96 {sharing.error && (
97 <div className="mt-2">
98 <ErrorText>{sharing.error}</ErrorText>
99 </div>
100 )}
101 </section>
102
103 <section className="mt-12">
104 <SectionTitle>Members</SectionTitle>
105 <p className="mb-4 text-xs leading-relaxed text-faint">
106 {space.kind === "private"
107 ? "Only the people, agents and teams here can open this space."
108 : "Everyone the space is for already has its base role; add people here to give them more. Workspace owners always have full access."}{" "}
109 An agent added here still never does more than the person it works for.
110 </p>
111 <Card asChild divided className="overflow-hidden">
112 <ul>
113 {members.map((m) => (
114 <li key={m.key} className="flex items-center gap-3 px-4 py-2.5">
115 {m.kind === "team" ? (
116 <span className="flex size-6 items-center justify-center rounded-full bg-raised text-xs">@</span>
117 ) : (
118 <Face who={{ kind: m.kind, id: m.key.slice(m.key.indexOf(":") + 1), name: m.name, avatar: m.avatar, avatar_seed: m.avatar_seed ?? null }} size={24} />
119 )}
120 <span className="min-w-0 grow">
121 <span className="block truncate text-sm">{m.display_name}</span>
122 <span className="block text-xs text-faint">{m.kind === "team" ? "Team" : m.kind === "agent" ? "Agent" : `@${m.name}`}</span>
123 </span>
124 <SelectField aria-label={`${m.display_name}'s role`} value={m.role} onValueChange={(role) => people.send("set_member", { space_id: space.id, member: m.key, role })} options={ROLE_OPTIONS} className="h-8 w-40" />
125 <Button type="button" onClick={() => people.send("set_member", { space_id: space.id, member: m.key, role: null })} variant="link" size="inline" className="text-xs text-faint hover:text-danger font-normal">
126 Remove
127 </Button>
128 </li>
129 ))}
130 {members.length === 0 && <li className="px-4 py-3 text-sm text-muted">Nobody added yet.</li>}
131 </ul>
132 </Card>
133 <div className="mt-3 flex flex-wrap items-center gap-2">
134 <SelectField aria-label="Person, agent or team" value={adding} onValueChange={setAdding} placeholder="Add a person, agent or team" options={candidates} className="h-9 min-w-64 grow" />
135 <SelectField aria-label="Their role" value={addRole} onValueChange={(r) => setAddRole(r as DocRole)} options={ROLE_OPTIONS} className="h-9 w-40" />
136 <Button
137 type="button"
138 variant="outline"
139 disabled={!adding || people.busy}
140 onClick={async () => {
141 // People are named by username here; the service keys them by id.
142 const member = adding.startsWith("username:") ? await memberKey(slug, adding.slice(9)) : adding;
143 if (!member) return people.setError("That person isn't in the workspace.");
144 const done = await people.send("set_member", { space_id: space.id, member, role: addRole });
145 if (done.ok) setAdding("");
146 }}
147 >
148 Add
149 </Button>
150 </div>
151 {people.error && (
152 <div className="mt-2">
153 <ErrorText>{people.error}</ErrorText>
154 </div>
155 )}
156 </section>
157
158 {!space.is_default && (
159 <section className="mt-12 rounded-xl border border-danger/30 p-5">
160 <h2 className="text-sm font-semibold">Archive this space</h2>
161 <p className="mt-1 text-xs leading-relaxed text-muted">It leaves the sidebar and search. What's in it, its history and comments are kept.</p>
162 <Button type="button" variant="destructive" onClick={() => setArchiving(true)} className="mt-3">
163 <Archive size={14} /> Archive space
164 </Button>
165 </section>
166 )}
167 <AlertDialog open={archiving} onOpenChange={setArchiving}>
168 <AlertDialogContent>
169 <AlertDialogHeader>
170 <AlertDialogTitle>Archive {space.name}?</AlertDialogTitle>
171 <AlertDialogDescription>Nobody will see it in Artifacts until it is brought back.</AlertDialogDescription>
172 </AlertDialogHeader>
173 <AlertDialogFooter>
174 <AlertDialogCancel>Keep it</AlertDialogCancel>
175 <AlertDialogAction
176 onClick={async () => {
177 const done = await settings.send("update_space", { space_id: space.id, space_change: { archived: true } });
178 if (done.ok) navigate(`/${slug}/-/artifacts`);
179 }}
180 >
181 Archive
182 </AlertDialogAction>
183 </AlertDialogFooter>
184 </AlertDialogContent>
185 </AlertDialog>
186 </div>
187 );
188}