"use client"; /** * version-compare-dialog — T-150/T-151 web surface: a read-only panel over * two page versions, opened from the history popover's "Compare". * * Two tabs: * - Compare: `GET /pages/:id/versions/compare?from&to` — the keyed * block-level diff (added / deleted / modified{fields} / moved + a * page-field diff). Block ids are stable across versions, so the list * is a JOIN summary, not a text diff (Ponytail: the block is the unit * of meaning in this editor). * - Preview: `GET /pages/:id/versions/:versionId` — the full snapshot, * rendered as a read-only text extraction per block (NEVER HTML — a * snapshot is untrusted input; `BlockRenderer` has no readOnly mode, * recorded as the honest fallback). */ import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { useQuery } from "@tanstack/react-query"; import { GitCompare, History, Loader2 } from "lucide-react"; import { pageApi } from "@/lib/api"; import { blockText } from "./version-text"; import { formatRelativeTime } from "@/lib/utils"; import type { PageVersion, VersionDiff, VersionPreview } from "@/lib/types"; interface VersionCompareDialogProps { pageId: string; /** The version the user picked in the history popover (the "to" side). */ toVersion: PageVersion; /** All listed versions (for the "from" selector). */ versions: PageVersion[]; onClose: () => void; } export function VersionCompareDialog({ pageId, toVersion, versions, onClose }: VersionCompareDialogProps) { const [tab, setTab] = useState<"compare" | "preview">("compare"); const [fromVersion, setFromVersion] = useState( () => versions.find((v) => v.version === toVersion.version - 1)?.version ?? toVersion.version ); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); const compareQuery = useQuery({ queryKey: ["pages", pageId, "versions", "compare", fromVersion, toVersion.version], queryFn: () => pageApi.compareVersions(pageId, fromVersion, toVersion.version), enabled: tab === "compare", retry: false }); const previewQuery = useQuery({ queryKey: ["pages", pageId, "versions", "preview", toVersion.id], queryFn: () => pageApi.previewVersion(pageId, toVersion.id), enabled: tab === "preview", retry: false }); if (typeof document === "undefined") return null; // Portal to : the topbar is `backdrop-blur` (a filter), which makes it // the containing block for `position: fixed` descendants — without the // portal the dialog is clipped to the 44px topbar strip. return createPortal(
{ if (e.target === e.currentTarget) onClose(); }} >

Compare v{fromVersion} → v{toVersion.version} {toVersion.note ? ` (${toVersion.note})` : ""}

{tab === "compare" ? ( ) : ( )}
, document.body ); } function ComparePane({ diff, loading, error, onPickFrom, versions, fromVersion, toVersionNumber }: { diff: VersionDiff | undefined; loading: boolean; error: Error | null; onPickFrom: (n: number) => void; versions: PageVersion[]; fromVersion: number; toVersionNumber: number; }) { if (loading) return ; if (error) return ; if (!diff) return null; const pageFields = Object.entries(diff.page); const list = (label: string, items: Array<{ id: string; type?: string; content?: unknown }>, tone: string) => items.length === 0 ? null : (

{label}

    {items.map((b) => (
  • {b.type} {blockText(b.content) || empty}
  • ))}
); return (
Compare against → v{toVersionNumber}

{diff.summary.added} added · {diff.summary.deleted} deleted · {diff.summary.modified} modified ·{" "} {diff.summary.moved} moved · {diff.summary.unchanged} unchanged

{pageFields.length > 0 && (

Page fields

    {pageFields.map(([field, { from, to }]) => (
  • {field} : {String(from ?? "—")} → {String(to ?? "—")}
  • ))}
)} {list("Added", diff.added, "text-emerald-600")} {list("Deleted", diff.deleted, "text-red-600")} {diff.modified.length > 0 && (

Modified

    {diff.modified.map((m) => (
  • {m.fields.join(", ")}
  • ))}
)} {diff.moved.length > 0 && (

Moved

    {diff.moved.map((m) => (
  • {m.from} → {m.to}
  • ))}
)}
); } function PreviewPane({ preview, loading, error }: { preview: VersionPreview | undefined; loading: boolean; error: Error | null; }) { if (loading) return ; if (error) return ; if (!preview) return null; const blocks = [...preview.snapshot.blocks].sort((a, b) => a.position.localeCompare(b.position)); return (

v{preview.version} · {preview.note ?? "no note"} · {new Date(preview.createdAt).toLocaleString()} ·{" "} {blocks.length} blocks

    {blocks.map((b) => (
  • {b.type} {blockText(b.content) || empty}
  • ))}
); } function Loading() { return (

Loading…

); } function PaneError({ message }: { message: string }) { return

{message}

; }