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