| 1 | import { folioIdFrom, type DocDiffLine, type Folio, type FolioVersion, type FolioVersionDetail } from "@g1t/contracts"; |
| 2 | import { ArrowLeft, History, RotateCcw } from "lucide-react"; |
| 3 | import { useEffect, useState } from "react"; |
| 4 | import { Link, data, redirect, useNavigate } from "react-router"; |
| 5 | |
| 6 | import type { Route } from "./+types/history"; |
| 7 | import { foliosQuery, foliosRequest } from "../../../components/folios/actions"; |
| 8 | import { FolioGlyph } from "../../../components/folios/kinds"; |
| 9 | import { Face } from "../../../components/folios/parts"; |
| 10 | import { Button, EmptyState, ErrorText } from "../../../components/ui"; |
| 11 | import { canDo } from "../../../lib/folios"; |
| 12 | import { page } from "../../../lib/meta"; |
| 13 | import { folios } from "../../../lib/services.server"; |
| 14 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 15 | |
| 16 | export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) { |
| 17 | return page(args, { title: `History · ${loaded?.folio.title || "Untitled"} · ${params.owner} · g1t` }); |
| 18 | } |
| 19 | |
| 20 | export 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 | |
| 33 | const KIND_LABELS: Record<FolioVersion["kind"], string> = { created: "Created", edit: "Edited", agent: "Edited by an agent", suggestion: "Suggestion accepted", proposal: "Proposal applied", restore: "Restored" }; |
| 34 | |
| 35 | function 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). */ |
| 50 | export 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 | } |