Skip to content
195 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({ open, onOpenChange, title, onSave }: { open: boolean; onOpenChange: (o: boolean) => void; title: string; onSave: (title: string) => Promise<Result<unknown>> }) {
18 const [value, setValue] = useState(title);
19 const [error, setError] = useState<string | null>(null);
20 const [busy, setBusy] = useState(false);
21 useEffect(() => {
22 if (open) {
23 setValue(title);
24 setError(null);
25 }
26 }, [open, title]);
27 return (
28 <Dialog open={open} onOpenChange={onOpenChange}>
29 <DialogContent>
30 <form
31 onSubmit={async (e) => {
32 e.preventDefault();
33 setBusy(true);
34 const done = await onSave(value.trim());
35 setBusy(false);
36 if (done.ok) onOpenChange(false);
37 else setError(done.error.message);
38 }}
39 >
40 <DialogHeader>
41 <DialogTitle>Rename</DialogTitle>
42 <DialogDescription>Its link keeps working: the address ends in its id.</DialogDescription>
43 </DialogHeader>
44 <div className="mt-4 space-y-3">
45 <input aria-label="Title" autoFocus maxLength={FOLIO_MAX_TITLE} placeholder="Untitled" value={value} onChange={(e) => setValue(e.target.value)} className={FIELD} />
46 {error && <ErrorText>{error}</ErrorText>}
47 </div>
48 <DialogFooter className="mt-4">
49 <Button type="submit" variant="accent" disabled={busy}>
50 {busy ? "Saving…" : "Rename"}
51 </Button>
52 </DialogFooter>
53 </form>
54 </DialogContent>
55 </Dialog>
56 );
57}
58
59/**
60 * Where an artifact can go: the top of a space the viewer can edit, or of
61 * their own Private, or inside a doc there. Never inside itself or
62 * anything under it.
63 */
64export function MoveDialog({
65 sidebar,
66 folio,
67 me,
68 open,
69 onOpenChange,
70 onMove,
71}: {
72 sidebar: FoliosSidebar | null | undefined;
73 folio: Pick<Folio, "id" | "title" | "space" | "parent_id" | "owner">;
74 /** The viewer's member key: only an artifact's owner can move it to their Private. */
75 me: string;
76 open: boolean;
77 onOpenChange: (o: boolean) => void;
78 onMove: (spaceId: string | null, parentId: string | null) => Promise<Result<unknown>>;
79}) {
80 const mine = `user:${folio.owner.id}` === me;
81 const places = useMemo(() => {
82 const out: { value: string; label: string; tree: ReturnType<typeof buildTree> }[] = [];
83 if (mine) out.push({ value: "private", label: "Private (just you)", tree: buildTree(sidebar?.private_tree ?? []) });
84 for (const s of sidebar?.spaces ?? []) if (canDo(s.viewer_role, "edit")) out.push({ value: s.id, label: s.name, tree: buildTree(s.tree) });
85 return out;
86 }, [sidebar, mine]);
87 const [place, setPlace] = useState(folio.space?.id ?? "private");
88 const [parent, setParent] = useState(folio.parent_id ?? "");
89 const [error, setError] = useState<string | null>(null);
90 const [busy, setBusy] = useState(false);
91 useEffect(() => {
92 if (!open) return;
93 setPlace(folio.space?.id ?? (mine ? "private" : (places[0]?.value ?? "")));
94 setParent(folio.parent_id ?? "");
95 setError(null);
96 }, [open, folio.space?.id, folio.parent_id, mine, places]);
97 const docs = useMemo(() => {
98 const tree = flatten(places.find((p) => p.value === place)?.tree ?? []);
99 const hidden = new Set<string>([folio.id]);
100 for (const item of tree) if (item.parent_id && hidden.has(item.parent_id)) hidden.add(item.id);
101 return tree.filter((i) => i.kind === "doc" && !hidden.has(i.id));
102 }, [places, place, folio.id]);
103 return (
104 <Dialog open={open} onOpenChange={onOpenChange}>
105 <DialogContent>
106 <DialogHeader>
107 <DialogTitle>Move “{folio.title || "Untitled"}”</DialogTitle>
108 <DialogDescription>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.</DialogDescription>
109 </DialogHeader>
110 {places.length === 0 ? (
111 <p className="text-sm text-muted">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.</p>
112 ) : (
113 <div className="space-y-3">
114 <SelectField
115 aria-label="Where"
116 value={place}
117 onValueChange={(v) => {
118 setPlace(v);
119 setParent("");
120 }}
121 options={places.map((p) => ({ value: p.value, label: p.label }))}
122 className="h-9 w-full"
123 />
124 <SelectField
125 aria-label="Inside"
126 value={parent}
127 onValueChange={setParent}
128 options={[{ value: "", label: "At the top" }, ...docs.map((p) => ({ value: p.id, label: `${" ".repeat(p.depth)}${p.icon ?? ""} ${p.title || "Untitled"}`.trim() }))]}
129 className="h-9 w-full"
130 />
131 {error && <ErrorText>{error}</ErrorText>}
132 </div>
133 )}
134 <DialogFooter>
135 <Button
136 type="button"
137 variant="accent"
138 disabled={busy || places.length === 0}
139 onClick={async () => {
140 setBusy(true);
141 const done = await onMove(place === "private" ? null : place, parent || null);
142 setBusy(false);
143 if (done.ok) onOpenChange(false);
144 else setError(done.error.message);
145 }}
146 >
147 {busy ? "Moving…" : "Move"}
148 </Button>
149 </DialogFooter>
150 </DialogContent>
151 </Dialog>
152 );
153}
154
155/** 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). */
156export function TemplateDialog({ open, onOpenChange, title, widens, onSave }: { open: boolean; onOpenChange: (o: boolean) => void; title: string; widens: boolean; onSave: (name: string, description: string) => Promise<Result<unknown>> }) {
157 const [name, setName] = useState(title);
158 const [description, setDescription] = useState("");
159 const [error, setError] = useState<string | null>(null);
160 useEffect(() => {
161 if (open) {
162 setName(title);
163 setError(null);
164 }
165 }, [open, title]);
166 return (
167 <Dialog open={open} onOpenChange={onOpenChange}>
168 <DialogContent>
169 <DialogHeader>
170 <DialogTitle>Save as a template</DialogTitle>
171 <DialogDescription>Everyone in the workspace will be able to use this template.</DialogDescription>
172 </DialogHeader>
173 <div className="space-y-3">
174 {widens && <p className="rounded-md border border-warn/30 bg-warn/10 px-3 py-2 text-xs leading-relaxed text-warn">Not everyone in the workspace can open this now. Its content will be in the template, which everyone can use.</p>}
175 <input aria-label="Name" value={name} onChange={(e) => setName(e.target.value)} className={FIELD} />
176 <input aria-label="What it's for" placeholder="What it's for" value={description} onChange={(e) => setDescription(e.target.value)} className={FIELD} />
177 {error && <ErrorText>{error}</ErrorText>}
178 </div>
179 <DialogFooter>
180 <Button
181 type="button"
182 variant="accent"
183 onClick={async () => {
184 const done = await onSave(name, description);
185 if (done.ok) onOpenChange(false);
186 else setError(done.error.message);
187 }}
188 >
189 Save template
190 </Button>
191 </DialogFooter>
192 </DialogContent>
193 </Dialog>
194 );
195}