Skip to content
195 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Artifacts is a mode in the rail, the shell and the phone's tab bar in place of Docs: its home lists everything you can open by day or as cards under All, Yours and Shared with you, with search, filters and tiles to start a doc (slides, designs and dashboards say they are coming soon), and its sidebar holds favorites, spaces, Private, Shared, projects' docs and the trash.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";
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.10import { ErrorText } from "../ui";
11import { Button } from "../ui/button";
Artifacts is a mode in the rail, the shell and the phone's tab bar in place of Docs: its home lists everything you can open by day or as cards under All, Yours and Shared with you, with search, filters and tiles to start a doc (slides, designs and dashboards say they are coming soon), and its sidebar holds favorites, spaces, Private, Shared, projects' docs and the trash.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}