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