Skip to content
133 linesCodeBlameRaw
1import { folioIdFrom, type DocDiffLine, type Folio, type FolioVersion, type FolioVersionDetail } from "@g1t/contracts";
2import { ArrowLeft, History, RotateCcw } from "lucide-react";
3import { useEffect, useState } from "react";
4import { Link, data, redirect, useNavigate } from "react-router";
5
6import type { Route } from "./+types/history";
7import { foliosQuery, foliosRequest } from "../../../components/folios/actions";
8import { FolioGlyph } from "../../../components/folios/kinds";
9import { Face } from "../../../components/folios/parts";
10import { Button, EmptyState, ErrorText } from "../../../components/ui";
11import { canDo } from "../../../lib/folios";
12import { page } from "../../../lib/meta";
13import { folios } from "../../../lib/services.server";
14import { requireUser, roleIn } from "../../../lib/session.server";
15
16export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
17 return page(args, { title: `History · ${loaded?.folio.title || "Untitled"} · ${params.owner} · g1t` });
18}
19
20export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ folio: Folio; versions: FolioVersion[] }> {
21 const viewer = requireUser(context, request);
22 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
23 const slug = params.owner.toLowerCase();
24 const id = folioIdFrom(params.folio);
25 if (!id) throw data(null, { status: 404 });
26 const [folio, versions] = await Promise.all([folios.folio(slug, viewer, id), folios.versions(slug, viewer, id)]);
27 if (!folio.ok || !versions.ok) throw data(null, { status: 404 });
28 const url = new URL(request.url);
29 if (url.pathname !== `${folio.value.path}/history`) throw redirect(`${folio.value.path}/history`);
30 return { folio: folio.value, versions: versions.value };
31}
32
33const KIND_LABELS: Record<FolioVersion["kind"], string> = { created: "Created", edit: "Edited", agent: "Edited by an agent", suggestion: "Suggestion accepted", proposal: "Proposal applied", restore: "Restored" };
34
35function DiffView({ lines }: { lines: DocDiffLine[] }) {
36 if (!lines.length) return <p className="p-4 text-xs text-faint">No changes in the text.</p>;
37 return (
38 <div className="font-mono text-[0.75rem] leading-relaxed">
39 {lines.map((l, i) => (
40 <div key={i} className={`flex gap-2 px-3 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"}`}>
41 <span className="w-3 shrink-0 text-faint select-none">{l.op === "add" ? "+" : l.op === "del" ? "−" : " "}</span>
42 <span className="min-w-0">{l.text || " "}</span>
43 </div>
44 ))}
45 </div>
46 );
47}
48
49/** An artifact's history: every version, who made it, what changed against the one before, and restoring one (a new version; nothing is lost). */
50export default function FolioHistory({ loaderData, params }: Route.ComponentProps) {
51 const slug = params.owner.toLowerCase();
52 const { folio, versions } = loaderData;
53 const navigate = useNavigate();
54 const [chosen, setChosen] = useState<string | null>(versions[0]?.id ?? null);
55 const [detail, setDetail] = useState<FolioVersionDetail | null>(null);
56 const [error, setError] = useState<string | null>(null);
57 const [restoring, setRestoring] = useState(false);
58 useEffect(() => {
59 if (!chosen) return setDetail(null);
60 setDetail(null);
61 void foliosQuery<FolioVersionDetail>(slug, { version: chosen, folio: folio.id }).then((r) => (r.ok ? setDetail(r.value) : setError(r.error.message)));
62 }, [chosen, slug, folio.id]);
63 const canRestore = canDo(folio.viewer_role, "edit") && !folio.trashed_at;
64 return (
65 <div className="mx-auto max-w-6xl">
66 <Link to={folio.path} className="inline-flex items-center gap-1.5 text-xs text-muted hover:text-fg">
67 <ArrowLeft size={13} /> Back to <FolioGlyph folio={folio} size={12} /> {folio.title || "Untitled"}
68 </Link>
69 <div className="mt-3 flex flex-wrap items-center gap-3">
70 <h1 className="flex grow items-center gap-2 text-xl font-semibold tracking-tight">
71 <History size={18} className="text-faint" /> History
72 </h1>
73 {canRestore && chosen && versions[0]?.id !== chosen && (
74 <Button
75 type="button"
76 variant="accent"
77 disabled={restoring}
78 onClick={async () => {
79 setRestoring(true);
80 const done = await foliosRequest(slug, "restore_version", { folio_id: folio.id, version_id: chosen });
81 setRestoring(false);
82 if (done.ok) navigate(folio.path);
83 else setError(done.error.message);
84 }}
85 >
86 <RotateCcw size={14} /> {restoring ? "Restoring…" : "Restore this version"}
87 </Button>
88 )}
89 </div>
90 <p className="mt-1 text-sm text-muted">Every version, who made it, and what changed. Restoring makes a new version; nothing is lost.</p>
91 {error && (
92 <div className="mt-3">
93 <ErrorText>{error}</ErrorText>
94 </div>
95 )}
96 {versions.length === 0 ? (
97 <div className="mt-6">
98 <EmptyState title="No versions yet">Versions are saved as people and agents edit.</EmptyState>
99 </div>
100 ) : (
101 <div className="mt-6 grid gap-4 md:grid-cols-[17rem_minmax(0,1fr)]">
102 <ul className="space-y-px md:max-h-[calc(100dvh-14rem)] md:overflow-y-auto md:[scrollbar-width:thin]">
103 {versions.map((v) => (
104 <li key={v.id}>
105 <button type="button" onClick={() => setChosen(v.id)} aria-current={chosen === 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"}`}>
106 <span className="block text-xs font-medium text-fg" suppressHydrationWarning>
107 {new Date(v.created_at).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}
108 </span>
109 <span className="mt-0.5 block text-[0.6875rem] text-faint">{v.note ?? KIND_LABELS[v.kind]}</span>
110 <span className="mt-1 flex items-center gap-1">
111 {v.authors.slice(0, 4).map((a) => (
112 <Face key={`${a.kind}:${a.id}`} who={a} size={14} />
113 ))}
114 <span className="ml-1 truncate text-[0.6875rem] text-muted">{v.authors.map((a) => a.display_name).join(", ")}</span>
115 </span>
116 </button>
117 </li>
118 ))}
119 </ul>
120 <div className="min-w-0 overflow-hidden rounded-xl border border-line bg-surface">
121 {detail ? (
122 <div className="max-h-[calc(100dvh-14rem)] overflow-auto py-2 [scrollbar-width:thin]">
123 <DiffView lines={detail.diff} />
124 </div>
125 ) : (
126 <p className="p-4 text-xs text-faint">{chosen ? "Loading…" : "Choose a version."}</p>
127 )}
128 </div>
129 </div>
130 )}
131 </div>
132 );
133}