Skip to content
458 linesCodeBlameRaw
1/**
2 * A page's surroundings: agents' suggestions with Accept and Reject, the
3 * history dialog, moving a page, saving it as a template, the emoji and
4 * cover pickers, and comments on the whole page.
5 */
6import type { DocDiffLine, DocSuggestion, DocVersion, DocVersionDetail, DocsSidebarSpace, MemberProfile, Result } from "@g1t/contracts";
7import { Check, History, MessageSquare, RotateCcw, Sparkles, X } from "lucide-react";
8import { type ReactNode, useEffect, useMemo, useState } from "react";
9
10import { COVER_GRADIENTS, buildTree, canDo, flatten } from "../../lib/docs";
11import { Button, ErrorText, TimeAgo } from "../ui";
12import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
13import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
14import { SelectField } from "../ui/select";
15import { docsQuery } from "./actions";
16import { Face } from "./parts";
17
18// ── Suggestions ─────────────────────────────────────────────────────────────
19
20function DiffBlock({ text, tone }: { text: string; tone: "del" | "add" }) {
21 if (!text.trim()) return <p className="text-xs text-faint italic">{tone === "del" ? "Nothing removed" : "Nothing added"}</p>;
22 return (
23 <pre
24 className={`max-h-56 overflow-auto rounded-md px-2.5 py-2 font-sans text-[0.8125rem] leading-relaxed whitespace-pre-wrap [scrollbar-width:thin] ${
25 tone === "del" ? "bg-danger/8 text-fg-soft line-through decoration-danger/60" : "bg-success/10 text-fg"
26 }`}
27 >
28 {text}
29 </pre>
30 );
31}
32
33function targetLabel(s: DocSuggestion): string {
34 switch (s.target.kind) {
35 case "append":
36 return "Adds to the end";
37 case "document":
38 return "Rewrites the page";
39 case "section":
40 return `Changes “${s.target.heading}”`;
41 default:
42 return s.block_ids.length === 1 ? "Changes one block" : `Changes ${s.block_ids.length} blocks`;
43 }
44}
45
46export function SuggestionCard({ suggestion, canDecide, onDecide, compact = false }: { suggestion: DocSuggestion; canDecide: boolean; onDecide: (id: string, decision: "accept" | "reject") => void; compact?: boolean }) {
47 const [open, setOpen] = useState(!compact);
48 const s = suggestion;
49 return (
50 <article className="rounded-xl border border-accent/30 bg-surface shadow-lg shadow-black/20">
51 <header className="flex items-center gap-2 border-b border-line px-3 py-2">
52 <Face who={s.author} size={20} />
53 <span className="min-w-0 grow">
54 <span className="block truncate text-xs font-medium text-fg">{s.author.display_name} suggests</span>
55 <span className="block truncate text-[0.6875rem] text-faint">
56 {targetLabel(s)}
57 {s.asked_by ? ` · for ${s.asked_by.display_name}` : ""} · <TimeAgo at={s.created_at} />
58 </span>
59 </span>
60 <Sparkles size={14} className="shrink-0 text-accent" aria-hidden="true" />
61 </header>
62 <div className="space-y-2 px-3 py-2.5">
63 {s.note && <p className="text-xs leading-relaxed text-muted">{s.note}</p>}
64 {open ? (
65 <>
66 {s.target.kind !== "append" && <DiffBlock text={s.before_markdown} tone="del" />}
67 <DiffBlock text={s.after_markdown} tone="add" />
68 </>
69 ) : (
70 <button type="button" onClick={() => setOpen(true)} className="text-xs text-accent hover:underline">
71 Show the change
72 </button>
73 )}
74 </div>
75 {canDecide && (
76 <footer className="flex gap-2 border-t border-line px-3 py-2">
77 <button type="button" onClick={() => onDecide(s.id, "accept")} className="inline-flex h-7 items-center gap-1 rounded-md bg-success/15 px-2.5 text-xs font-medium text-success hover:bg-success/25">
78 <Check size={13} /> Accept
79 </button>
80 <button type="button" onClick={() => onDecide(s.id, "reject")} className="inline-flex h-7 items-center gap-1 rounded-md px-2.5 text-xs text-muted hover:bg-raised hover:text-fg">
81 <X size={13} /> Reject
82 </button>
83 </footer>
84 )}
85 </article>
86 );
87}
88
89// ── History ─────────────────────────────────────────────────────────────────
90
91const KIND_LABELS: Record<DocVersion["kind"], string> = { created: "Created", edit: "Edited", agent: "Edited by an agent", suggestion: "Suggestion accepted", restore: "Restored" };
92
93function DiffView({ lines }: { lines: DocDiffLine[] }) {
94 return (
95 <div className="font-mono text-[0.75rem] leading-relaxed">
96 {lines.map((l, i) => (
97 <div key={i} className={`flex gap-2 px-2 whitespace-pre-wrap ${l.op === "add" ? "bg-success/10 text-fg" : l.op === "del" ? "bg-danger/10 text-fg-soft line-through decoration-danger/50" : "text-muted"}`}>
98 <span className="w-3 shrink-0 text-faint select-none">{l.op === "add" ? "+" : l.op === "del" ? "−" : " "}</span>
99 <span className="min-w-0">{l.text || " "}</span>
100 </div>
101 ))}
102 </div>
103 );
104}
105
106export function HistoryDialog({ slug, pageId, open, onOpenChange, canRestore, onRestore }: { slug: string; pageId: string; open: boolean; onOpenChange: (o: boolean) => void; canRestore: boolean; onRestore: (versionId: string) => Promise<Result<unknown>> }) {
107 const [versions, setVersions] = useState<DocVersion[] | null>(null);
108 const [chosen, setChosen] = useState<string | null>(null);
109 const [detail, setDetail] = useState<DocVersionDetail | null>(null);
110 const [error, setError] = useState<string | null>(null);
111 const [restoring, setRestoring] = useState(false);
112 useEffect(() => {
113 if (!open) return;
114 setVersions(null);
115 setError(null);
116 void docsQuery<DocVersion[]>(slug, { versions: pageId }).then((r) => {
117 if (r.ok) {
118 setVersions(r.value);
119 setChosen(r.value[0]?.id ?? null);
120 } else setError(r.error.message);
121 });
122 }, [open, slug, pageId]);
123 useEffect(() => {
124 if (!chosen) return setDetail(null);
125 setDetail(null);
126 void docsQuery<DocVersionDetail>(slug, { version: chosen, page: pageId }).then((r) => r.ok && setDetail(r.value));
127 }, [chosen, slug, pageId]);
128 return (
129 <Dialog open={open} onOpenChange={onOpenChange}>
130 <DialogContent className="max-w-5xl">
131 <DialogHeader>
132 <DialogTitle className="flex items-center gap-2">
133 <History size={16} /> History
134 </DialogTitle>
135 <DialogDescription>Every version, who made it, and what changed. Restoring makes a new version; nothing is lost.</DialogDescription>
136 </DialogHeader>
137 {error && <ErrorText>{error}</ErrorText>}
138 <div className="grid min-h-[24rem] gap-4 md:grid-cols-[16rem_minmax(0,1fr)]">
139 <ul className="max-h-[60vh] space-y-px overflow-y-auto [scrollbar-width:thin]">
140 {versions === null && !error && <li className="px-2 py-1 text-xs text-faint">Loading…</li>}
141 {versions?.map((v) => (
142 <li key={v.id}>
143 <button type="button" onClick={() => setChosen(v.id)} className={`w-full rounded-md px-2.5 py-2 text-left transition-colors ${chosen === v.id ? "bg-raised" : "hover:bg-raised/60"}`}>
144 <span className="block text-xs font-medium text-fg">{new Date(v.created_at).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}</span>
145 <span className="mt-0.5 block text-[0.6875rem] text-faint">{v.note ?? KIND_LABELS[v.kind]}</span>
146 <span className="mt-1 flex items-center gap-1">
147 {v.authors.slice(0, 4).map((a) => (
148 <Face key={`${a.kind}:${a.id}`} who={a} size={14} />
149 ))}
150 <span className="ml-1 truncate text-[0.6875rem] text-muted">{v.authors.map((a) => a.display_name).join(", ")}</span>
151 </span>
152 </button>
153 </li>
154 ))}
155 </ul>
156 <div className="min-w-0 overflow-hidden rounded-lg border border-line">
157 {detail ? (
158 <div className="max-h-[60vh] overflow-auto py-2 [scrollbar-width:thin]">
159 <DiffView lines={detail.diff} />
160 </div>
161 ) : (
162 <p className="p-4 text-xs text-faint">{chosen ? "Loading…" : "Choose a version."}</p>
163 )}
164 </div>
165 </div>
166 <DialogFooter>
167 {canRestore && chosen && versions?.[0]?.id !== chosen && (
168 <Button
169 type="button"
170 variant="accent"
171 disabled={restoring}
172 onClick={async () => {
173 setRestoring(true);
174 const done = await onRestore(chosen);
175 setRestoring(false);
176 if (done.ok) onOpenChange(false);
177 else setError(done.error.message);
178 }}
179 >
180 <RotateCcw size={14} /> {restoring ? "Restoring…" : "Restore this version"}
181 </Button>
182 )}
183 </DialogFooter>
184 </DialogContent>
185 </Dialog>
186 );
187}
188
189// ── Move ────────────────────────────────────────────────────────────────────
190
191export function MoveDialog({ spaces, pageId, current, open, onOpenChange, onMove }: { spaces: DocsSidebarSpace[]; pageId: string; current: { space_id: string; parent_id: string | null }; open: boolean; onOpenChange: (o: boolean) => void; onMove: (spaceId: string, parentId: string | null) => void }) {
192 const writable = spaces.filter((s) => canDo(s.viewer_role, "edit"));
193 const [space, setSpace] = useState(current.space_id);
194 const [parent, setParent] = useState(current.parent_id ?? "");
195 const pages = useMemo(() => {
196 const s = writable.find((x) => x.id === space);
197 if (!s) return [];
198 const tree = flatten(buildTree(s.pages));
199 // Not under itself or anything under it.
200 const hidden = new Set<string>([pageId]);
201 for (const item of tree) if (item.parent_id && hidden.has(item.parent_id)) hidden.add(item.id);
202 return tree.filter((i) => !hidden.has(i.id));
203 }, [writable, space, pageId]);
204 return (
205 <Dialog open={open} onOpenChange={onOpenChange}>
206 <DialogContent>
207 <DialogHeader>
208 <DialogTitle>Move page</DialogTitle>
209 <DialogDescription>Pages under it move with it.</DialogDescription>
210 </DialogHeader>
211 <div className="space-y-3">
212 <SelectField aria-label="Space" value={space} onValueChange={(v) => { setSpace(v); setParent(""); }} options={writable.map((s) => ({ value: s.id, label: s.name }))} className="h-9 w-full" />
213 <SelectField
214 aria-label="Inside"
215 value={parent}
216 onValueChange={setParent}
217 options={[{ value: "", label: "At the top of the space" }, ...pages.map((p) => ({ value: p.id, label: `${" ".repeat(p.depth)}${p.icon ?? ""} ${p.title || "Untitled"}`.trim() }))]}
218 className="h-9 w-full"
219 />
220 </div>
221 <DialogFooter>
222 <Button type="button" variant="accent" onClick={() => onMove(space, parent || null)}>
223 Move
224 </Button>
225 </DialogFooter>
226 </DialogContent>
227 </Dialog>
228 );
229}
230
231// ── Save as template ───────────────────────────────────────────────────────
232
233export function TemplateDialog({ open, onOpenChange, title, onSave }: { open: boolean; onOpenChange: (o: boolean) => void; title: string; onSave: (name: string, description: string) => Promise<Result<unknown>> }) {
234 const [name, setName] = useState(title);
235 const [description, setDescription] = useState("");
236 const [error, setError] = useState<string | null>(null);
237 useEffect(() => setName(title), [title]);
238 return (
239 <Dialog open={open} onOpenChange={onOpenChange}>
240 <DialogContent>
241 <DialogHeader>
242 <DialogTitle>Save as a template</DialogTitle>
243 <DialogDescription>Everyone in the workspace can start a page from it.</DialogDescription>
244 </DialogHeader>
245 <div className="space-y-3">
246 <input aria-label="Name" value={name} onChange={(e) => setName(e.target.value)} className="h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none focus:border-accent/60" />
247 <input aria-label="What it's for" placeholder="What it's for" value={description} onChange={(e) => setDescription(e.target.value)} className="h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none focus:border-accent/60" />
248 {error && <ErrorText>{error}</ErrorText>}
249 </div>
250 <DialogFooter>
251 <Button
252 type="button"
253 variant="accent"
254 onClick={async () => {
255 const done = await onSave(name, description);
256 if (done.ok) onOpenChange(false);
257 else setError(done.error.message);
258 }}
259 >
260 Save template
261 </Button>
262 </DialogFooter>
263 </DialogContent>
264 </Dialog>
265 );
266}
267
268// ── Icon and cover ─────────────────────────────────────────────────────────
269
270const EMOJI = ["📄", "📘", "📚", "📝", "📐", "🧭", "🗺️", "🚀", "🛠️", "🧰", "🧪", "🐛", "🔒", "🔑", "⚙️", "📦", "🧩", "🎯", "📈", "📊", "🗓️", "⏱️", "✅", "⚠️", "🔥", "🩹", "💡", "🧠", "🤖", "👋", "🤝", "💬", "📣", "🏁", "🌱", "⭐"];
271
272export function IconPicker({ value, onChange, children }: { value: string | null; onChange: (icon: string | null) => void; children: ReactNode }) {
273 const [open, setOpen] = useState(false);
274 const [custom, setCustom] = useState("");
275 return (
276 <Popover open={open} onOpenChange={setOpen}>
277 <PopoverTrigger asChild>{children}</PopoverTrigger>
278 <PopoverContent align="start" className="w-72 p-3">
279 <div className="grid grid-cols-9 gap-0.5">
280 {EMOJI.map((e) => (
281 <button
282 key={e}
283 type="button"
284 onClick={() => {
285 onChange(e);
286 setOpen(false);
287 }}
288 className={`flex size-7 items-center justify-center rounded text-base hover:bg-raised ${value === e ? "bg-raised" : ""}`}
289 aria-label={`Use ${e}`}
290 >
291 {e}
292 </button>
293 ))}
294 </div>
295 <form
296 className="mt-2 flex gap-1.5"
297 onSubmit={(e) => {
298 e.preventDefault();
299 if (custom.trim()) onChange(custom.trim());
300 setOpen(false);
301 }}
302 >
303 <input value={custom} onChange={(e) => setCustom(e.target.value)} placeholder="Any emoji" aria-label="Any emoji" className="h-8 min-w-0 grow rounded-md border border-line bg-bg px-2 text-sm outline-none focus:border-accent/60" />
304 <button type="submit" className="h-8 rounded-md bg-raised px-2 text-xs hover:bg-line">
305 Use
306 </button>
307 </form>
308 {value && (
309 <button
310 type="button"
311 onClick={() => {
312 onChange(null);
313 setOpen(false);
314 }}
315 className="mt-2 text-xs text-faint hover:text-fg"
316 >
317 Remove icon
318 </button>
319 )}
320 </PopoverContent>
321 </Popover>
322 );
323}
324
325export function CoverPicker({ onChange, children }: { onChange: (cover: string | null) => void; children: ReactNode }) {
326 const [open, setOpen] = useState(false);
327 return (
328 <Popover open={open} onOpenChange={setOpen}>
329 <PopoverTrigger asChild>{children}</PopoverTrigger>
330 <PopoverContent align="start" className="w-72 p-3">
331 <div className="grid grid-cols-3 gap-2">
332 {COVER_GRADIENTS.map((g, i) => (
333 <button
334 key={i}
335 type="button"
336 aria-label={`Cover ${i + 1}`}
337 onClick={() => {
338 onChange(`gradient:${i}`);
339 setOpen(false);
340 }}
341 className="h-12 rounded-md ring-1 ring-line hover:ring-accent"
342 style={{ background: g }}
343 />
344 ))}
345 </div>
346 <button
347 type="button"
348 onClick={() => {
349 onChange(null);
350 setOpen(false);
351 }}
352 className="mt-3 text-xs text-faint hover:text-fg"
353 >
354 Remove cover
355 </button>
356 </PopoverContent>
357 </Popover>
358 );
359}
360
361// ── Comments on the whole page ─────────────────────────────────────────────
362
363export type PageThread = { id: string; resolved: boolean; comments: { id: string; author: string; text: string; created_at: number }[] };
364
365export function Discussion({
366 threads,
367 people,
368 canComment,
369 onPost,
370 onReply,
371 onResolve,
372}: {
373 threads: PageThread[];
374 people: (key: string) => Pick<MemberProfile, "kind" | "id" | "name" | "display_name" | "avatar" | "avatar_seed">;
375 canComment: boolean;
376 onPost: (text: string) => Promise<boolean>;
377 onReply: (thread: string, text: string) => Promise<boolean>;
378 onResolve: (thread: string, resolved: boolean) => void;
379}) {
380 const [draft, setDraft] = useState("");
381 const [replies, setReplies] = useState<Record<string, string>>({});
382 const open = threads.filter((t) => !t.resolved);
383 const resolved = threads.filter((t) => t.resolved);
384 const [showResolved, setShowResolved] = useState(false);
385 const thread = (t: PageThread) => (
386 <li key={t.id} className={`rounded-xl border border-line bg-surface ${t.resolved ? "opacity-70" : ""}`}>
387 <ul className="divide-y divide-line">
388 {t.comments.map((c) => {
389 const who = people(c.author);
390 return (
391 <li key={c.id} className="flex gap-2.5 px-3.5 py-3">
392 <Face who={who} size={22} />
393 <div className="min-w-0 grow">
394 <p className="text-xs">
395 <span className="font-medium text-fg">{who.display_name}</span> <span className="text-faint">{c.created_at ? <TimeAgo at={c.created_at} /> : null}</span>
396 </p>
397 <p className="mt-0.5 text-sm leading-relaxed whitespace-pre-wrap text-fg-soft">{c.text}</p>
398 </div>
399 </li>
400 );
401 })}
402 </ul>
403 {canComment && (
404 <form
405 className="flex items-center gap-2 border-t border-line px-3 py-2"
406 onSubmit={async (e) => {
407 e.preventDefault();
408 const text = (replies[t.id] ?? "").trim();
409 if (text && (await onReply(t.id, text))) setReplies((r) => ({ ...r, [t.id]: "" }));
410 }}
411 >
412 <input value={replies[t.id] ?? ""} onChange={(e) => setReplies((r) => ({ ...r, [t.id]: e.target.value }))} placeholder="Reply" aria-label="Reply" className="h-8 min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint" />
413 <button type="button" onClick={() => onResolve(t.id, !t.resolved)} className="shrink-0 text-xs text-faint hover:text-fg">
414 {t.resolved ? "Reopen" : "Resolve"}
415 </button>
416 </form>
417 )}
418 </li>
419 );
420 return (
421 <section aria-labelledby="discussion" className="mt-14 border-t border-line pt-8">
422 <h2 id="discussion" className="flex items-center gap-2 text-sm font-semibold">
423 <MessageSquare size={15} /> Discussion
424 </h2>
425 <p className="mt-1 text-xs text-faint">Comments on the whole page. To comment on a passage, select it and choose Comment.</p>
426 <ul className="mt-4 space-y-3">{open.map(thread)}</ul>
427 {resolved.length > 0 && (
428 <>
429 <button type="button" onClick={() => setShowResolved(!showResolved)} className="mt-3 text-xs text-faint hover:text-fg">
430 {showResolved ? "Hide" : "Show"} {resolved.length} resolved
431 </button>
432 {showResolved && <ul className="mt-3 space-y-3">{resolved.map(thread)}</ul>}
433 </>
434 )}
435 {canComment && (
436 <form
437 className="mt-4 flex items-start gap-2"
438 onSubmit={async (e) => {
439 e.preventDefault();
440 if (draft.trim() && (await onPost(draft.trim()))) setDraft("");
441 }}
442 >
443 <textarea
444 value={draft}
445 onChange={(e) => setDraft(e.target.value)}
446 rows={2}
447 placeholder="Add a comment. @mention someone to tell them."
448 aria-label="Add a comment"
449 className="min-h-16 min-w-0 grow resize-y rounded-lg border border-line bg-surface px-3 py-2 text-sm outline-none placeholder:text-faint focus:border-accent/60"
450 />
451 <Button type="submit" variant="quiet" disabled={!draft.trim()}>
452 Comment
453 </Button>
454 </form>
455 )}
456 </section>
457 );
458}