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.

242 linesCodeBlameRaw
1/**
2 * The small dialogs an artifact's ⋯ menu opens, from Home's rows and from
3 * its own page: rename, move (to a space or Private, inside a doc), and
4 * save as a template.
5 */
6import { FOLIO_MAX_TITLE, type Folio, type FoliosSidebar, type Result } from "@g1t/contracts";
7import { useEffect, useMemo, useState } from "react";
8
9import { buildTree, canDo, flatten } from "../../lib/folios";
10import { ErrorText } from "../ui";
11import { Button } from "../ui/button";
12import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
13import { SelectField } from "../ui/select";
14
15const FIELD = "h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none focus:border-accent/60";
16
17export function RenameDialog({
18 open,
19 onOpenChange,
20 title,
21 onSave,
22}: {
23 open: boolean;
24 onOpenChange: (o: boolean) => void;
25 title: string;
26 onSave: (title: string) => Promise<Result<unknown>>;
27}) {
28 const [value, setValue] = useState(title);
29 const [error, setError] = useState<string | null>(null);
30 const [busy, setBusy] = useState(false);
31 useEffect(() => {
32 if (open) {
33 setValue(title);
34 setError(null);
35 }
36 }, [open, title]);
37 return (
38 <Dialog open={open} onOpenChange={onOpenChange}>
39 <DialogContent>
40 <form
41 onSubmit={async (e) => {
42 e.preventDefault();
43 setBusy(true);
44 const done = await onSave(value.trim());
45 setBusy(false);
46 if (done.ok) onOpenChange(false);
47 else setError(done.error.message);
48 }}
49 >
50 <DialogHeader>
51 <DialogTitle>Rename</DialogTitle>
52 <DialogDescription>Its link keeps working: the address ends in its id.</DialogDescription>
53 </DialogHeader>
54 <div className="mt-4 space-y-3">
55 <input
56 aria-label="Title"
57 autoFocus
58 maxLength={FOLIO_MAX_TITLE}
59 placeholder="Untitled"
60 value={value}
61 onChange={(e) => setValue(e.target.value)}
62 className={FIELD}
63 />
64 {error && <ErrorText>{error}</ErrorText>}
65 </div>
66 <DialogFooter className="mt-4">
67 <Button type="submit" variant="accent" disabled={busy}>
68 {busy ? "Saving…" : "Rename"}
69 </Button>
70 </DialogFooter>
71 </form>
72 </DialogContent>
73 </Dialog>
74 );
75}
76
77/**
78 * Where an artifact can go: the top of a space the viewer can edit, or of
79 * their own Private, or inside a doc there. Never inside itself or
80 * anything under it.
81 */
82export function MoveDialog({
83 sidebar,
84 folio,
85 me,
86 open,
87 onOpenChange,
88 onMove,
89}: {
90 sidebar: FoliosSidebar | null | undefined;
91 folio: Pick<Folio, "id" | "title" | "space" | "parent_id" | "owner">;
92 /** The viewer's member key: only an artifact's owner can move it to their Private. */
93 me: string;
94 open: boolean;
95 onOpenChange: (o: boolean) => void;
96 onMove: (spaceId: string | null, parentId: string | null) => Promise<Result<unknown>>;
97}) {
98 const mine = `user:${folio.owner.id}` === me;
99 const places = useMemo(() => {
100 const out: { value: string; label: string; tree: ReturnType<typeof buildTree> }[] = [];
101 if (mine) out.push({ value: "private", label: "Private (just you)", tree: buildTree(sidebar?.private_tree ?? []) });
102 for (const s of sidebar?.spaces ?? []) if (canDo(s.viewer_role, "edit")) out.push({ value: s.id, label: s.name, tree: buildTree(s.tree) });
103 return out;
104 }, [sidebar, mine]);
105 const [place, setPlace] = useState(folio.space?.id ?? "private");
106 const [parent, setParent] = useState(folio.parent_id ?? "");
107 const [error, setError] = useState<string | null>(null);
108 const [busy, setBusy] = useState(false);
109 useEffect(() => {
110 if (!open) return;
111 setPlace(folio.space?.id ?? (mine ? "private" : (places[0]?.value ?? "")));
112 setParent(folio.parent_id ?? "");
113 setError(null);
114 }, [open, folio.space?.id, folio.parent_id, mine, places]);
115 const docs = useMemo(() => {
116 const tree = flatten(places.find((p) => p.value === place)?.tree ?? []);
117 const hidden = new Set<string>([folio.id]);
118 for (const item of tree) if (item.parent_id && hidden.has(item.parent_id)) hidden.add(item.id);
119 return tree.filter((i) => i.kind === "doc" && !hidden.has(i.id));
120 }, [places, place, folio.id]);
121 return (
122 <Dialog open={open} onOpenChange={onOpenChange}>
123 <DialogContent>
124 <DialogHeader>
125 <DialogTitle>Move “{folio.title || "Untitled"}”</DialogTitle>
126 <DialogDescription>
127 Everything inside it moves with it. In a space, it follows the space&apos;s access unless it&apos;s set to only people invited.
128 </DialogDescription>
129 </DialogHeader>
130 {places.length === 0 ? (
131 <p className="text-sm text-muted">
132 There&apos;s nowhere you can move it: you can&apos;t add to any space, and only its owner can move it to their Private.
133 </p>
134 ) : (
135 <div className="space-y-3">
136 <SelectField
137 aria-label="Where"
138 value={place}
139 onValueChange={(v) => {
140 setPlace(v);
141 setParent("");
142 }}
143 options={places.map((p) => ({ value: p.value, label: p.label }))}
144 className="h-9 w-full"
145 />
146 <SelectField
147 aria-label="Inside"
148 value={parent}
149 onValueChange={setParent}
150 options={[
151 { value: "", label: "At the top" },
152 ...docs.map((p) => ({ value: p.id, label: `${" ".repeat(p.depth)}${p.icon ?? ""} ${p.title || "Untitled"}`.trim() })),
153 ]}
154 className="h-9 w-full"
155 />
156 {error && <ErrorText>{error}</ErrorText>}
157 </div>
158 )}
159 <DialogFooter>
160 <Button
161 type="button"
162 variant="accent"
163 disabled={busy || places.length === 0}
164 onClick={async () => {
165 setBusy(true);
166 const done = await onMove(place === "private" ? null : place, parent || null);
167 setBusy(false);
168 if (done.ok) onOpenChange(false);
169 else setError(done.error.message);
170 }}
171 >
172 {busy ? "Moving…" : "Move"}
173 </Button>
174 </DialogFooter>
175 </DialogContent>
176 </Dialog>
177 );
178}
179
180/** Save as a template. From something not everyone can read, it says so first: the template is everyone's (plan section 4.3 rule 8). */
181export function TemplateDialog({
182 open,
183 onOpenChange,
184 title,
185 widens,
186 onSave,
187}: {
188 open: boolean;
189 onOpenChange: (o: boolean) => void;
190 title: string;
191 widens: boolean;
192 onSave: (name: string, description: string) => Promise<Result<unknown>>;
193}) {
194 const [name, setName] = useState(title);
195 const [description, setDescription] = useState("");
196 const [error, setError] = useState<string | null>(null);
197 useEffect(() => {
198 if (open) {
199 setName(title);
200 setError(null);
201 }
202 }, [open, title]);
203 return (
204 <Dialog open={open} onOpenChange={onOpenChange}>
205 <DialogContent>
206 <DialogHeader>
207 <DialogTitle>Save as a template</DialogTitle>
208 <DialogDescription>Everyone in the workspace will be able to use this template.</DialogDescription>
209 </DialogHeader>
210 <div className="space-y-3">
211 {widens && (
212 <p className="rounded-md border border-warn/30 bg-warn/10 px-3 py-2 text-xs leading-relaxed text-warn">
213 Not everyone in the workspace can open this now. Its content will be in the template, which everyone can use.
214 </p>
215 )}
216 <input aria-label="Name" value={name} onChange={(e) => setName(e.target.value)} className={FIELD} />
217 <input
218 aria-label="What it's for"
219 placeholder="What it's for"
220 value={description}
221 onChange={(e) => setDescription(e.target.value)}
222 className={FIELD}
223 />
224 {error && <ErrorText>{error}</ErrorText>}
225 </div>
226 <DialogFooter>
227 <Button
228 type="button"
229 variant="accent"
230 onClick={async () => {
231 const done = await onSave(name, description);
232 if (done.ok) onOpenChange(false);
233 else setError(done.error.message);
234 }}
235 >
236 Save template
237 </Button>
238 </DialogFooter>
239 </DialogContent>
240 </Dialog>
241 );
242}