1"use client";
2
3
18import { useEffect, useState } from "react";
19import { createPortal } from "react-dom";
20import { useQuery } from "@tanstack/react-query";
21import { GitCompare, History, Loader2 } from "lucide-react";
22import { pageApi } from "@/lib/api";
23import { blockText } from "./version-text";
24import { formatRelativeTime } from "@/lib/utils";
25import type { PageVersion, VersionDiff, VersionPreview } from "@/lib/types";
26
27interface VersionCompareDialogProps {
28 pageId: string;
29
30 toVersion: PageVersion;
31
32 versions: PageVersion[];
33 onClose: () => void;
34}
35
36export function VersionCompareDialog({ pageId, toVersion, versions, onClose }: VersionCompareDialogProps) {
37 const [tab, setTab] = useState<"compare" | "preview">("compare");
38 const [fromVersion, setFromVersion] = useState<number>(
39 () => versions.find((v) => v.version === toVersion.version - 1)?.version ?? toVersion.version
40 );
41
42 useEffect(() => {
43 const onKey = (e: KeyboardEvent) => {
44 if (e.key === "Escape") onClose();
45 };
46 window.addEventListener("keydown", onKey);
47 return () => window.removeEventListener("keydown", onKey);
48 }, [onClose]);
49
50 const compareQuery = useQuery({
51 queryKey: ["pages", pageId, "versions", "compare", fromVersion, toVersion.version],
52 queryFn: () => pageApi.compareVersions(pageId, fromVersion, toVersion.version),
53 enabled: tab === "compare",
54 retry: false
55 });
56
57 const previewQuery = useQuery({
58 queryKey: ["pages", pageId, "versions", "preview", toVersion.id],
59 queryFn: () => pageApi.previewVersion(pageId, toVersion.id),
60 enabled: tab === "preview",
61 retry: false
62 });
63
64 if (typeof document === "undefined") return null;
65
66
67
68 return createPortal(
69 <div
70 className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-6 pt-16"
71 role="dialog"
72 aria-modal="true"
73 aria-label="Version compare and preview"
74 onMouseDown={(e) => {
75 if (e.target === e.currentTarget) onClose();
76 }}
77 >
78 <div className="w-full max-w-2xl rounded-lg border border-border bg-background shadow-xl" data-testid="version-compare-dialog">
79 <div className="flex items-center gap-2 border-b border-border px-4 py-3">
80 <GitCompare className="size-4 text-muted-foreground" aria-hidden />
81 <h2 className="flex-1 text-sm font-semibold">
82 Compare v{fromVersion} → v{toVersion.version}
83 {toVersion.note ? ` (${toVersion.note})` : ""}
84 </h2>
85 <button type="button" onClick={onClose} className="rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent" data-testid="version-compare-close">
86 Close
87 </button>
88 </div>
89
90 <div className="flex gap-1 border-b border-border px-4 pt-2" role="tablist" aria-label="Version views">
91 <button
92 type="button"
93 role="tab"
94 aria-selected={tab === "compare"}
95 data-testid="version-tab-compare"
96 onClick={() => setTab("compare")}
97 className={`rounded-t px-3 py-1.5 text-sm ${tab === "compare" ? "border border-b-0 border-border bg-muted font-medium" : "text-muted-foreground hover:bg-accent"}`}
98 >
99 Changes
100 </button>
101 <button
102 type="button"
103 role="tab"
104 aria-selected={tab === "preview"}
105 data-testid="version-tab-preview"
106 onClick={() => setTab("preview")}
107 className={`rounded-t px-3 py-1.5 text-sm ${tab === "preview" ? "border border-b-0 border-border bg-muted font-medium" : "text-muted-foreground hover:bg-accent"}`}
108 >
109 <span className="inline-flex items-center gap-1">
110 <History className="size-3.5" aria-hidden /> Preview v{toVersion.version}
111 </span>
112 </button>
113 </div>
114
115 <div className="max-h-[60vh] overflow-y-auto p-4">
116 {tab === "compare" ? (
117 <ComparePane diff={compareQuery.data} loading={compareQuery.isLoading} error={compareQuery.error as Error | null} onPickFrom={setFromVersion} versions={versions} fromVersion={fromVersion} toVersionNumber={toVersion.version} />
118 ) : (
119 <PreviewPane preview={previewQuery.data} loading={previewQuery.isLoading} error={previewQuery.error as Error | null} />
120 )}
121 </div>
122 </div>
123 </div>,
124 document.body
125 );
126}
127
128function ComparePane({
129 diff,
130 loading,
131 error,
132 onPickFrom,
133 versions,
134 fromVersion,
135 toVersionNumber
136}: {
137 diff: VersionDiff | undefined;
138 loading: boolean;
139 error: Error | null;
140 onPickFrom: (n: number) => void;
141 versions: PageVersion[];
142 fromVersion: number;
143 toVersionNumber: number;
144}) {
145 if (loading) return <Loading />;
146 if (error) return <PaneError message={error.message} />;
147 if (!diff) return null;
148
149 const pageFields = Object.entries(diff.page);
150 const list = (label: string, items: Array<{ id: string; type?: string; content?: unknown }>, tone: string) =>
151 items.length === 0 ? null : (
152 <section className="mb-4">
153 <h3 className={`mb-1 text-xs font-semibold uppercase tracking-wide ${tone}`}>{label}</h3>
154 <ul className="space-y-0.5">
155 {items.map((b) => (
156 <li key={b.id} className="flex gap-2 text-sm" data-testid={`version-diff-item-${b.id}`}>
157 <span className="shrink-0 rounded bg-muted px-1 text-[10px] text-muted-foreground">{b.type}</span>
158 <span className="min-w-0 flex-1 truncate">{blockText(b.content) || <em className="text-muted-foreground">empty</em>}</span>
159 </li>
160 ))}
161 </ul>
162 </section>
163 );
164
165 return (
166 <div>
167 <div className="mb-4 flex flex-wrap items-center gap-2 text-sm">
168 <span>Compare against</span>
169 <select
170 data-testid="version-diff-from"
171 value={fromVersion}
172 onChange={(e) => onPickFrom(Number(e.target.value))}
173 className="rounded-md border border-border bg-background px-2 py-1 text-sm"
174 aria-label="Base version"
175 >
176 {versions
177 .filter((v) => v.version !== toVersionNumber)
178 .map((v) => (
179 <option key={v.id} value={v.version}>
180 v{v.version}
181 {v.note ? ` — ${v.note}` : ""}
182 </option>
183 ))}
184 </select>
185 <span className="text-muted-foreground">→ v{toVersionNumber}</span>
186 </div>
187
188 <p className="mb-4 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground" data-testid="version-diff-summary">
189 {diff.summary.added} added · {diff.summary.deleted} deleted · {diff.summary.modified} modified ·{" "}
190 {diff.summary.moved} moved · {diff.summary.unchanged} unchanged
191 </p>
192
193 {pageFields.length > 0 && (
194 <section className="mb-4">
195 <h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Page fields</h3>
196 <ul className="space-y-0.5">
197 {pageFields.map(([field, { from, to }]) => (
198 <li key={field} className="text-sm" data-testid={`version-diff-page-${field}`}>
199 <span className="font-medium">{field}</span>
200 <span className="text-muted-foreground">: {String(from ?? "—")} → {String(to ?? "—")}</span>
201 </li>
202 ))}
203 </ul>
204 </section>
205 )}
206
207 {list("Added", diff.added, "text-emerald-600")}
208 {list("Deleted", diff.deleted, "text-red-600")}
209 {diff.modified.length > 0 && (
210 <section className="mb-4">
211 <h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-amber-600">Modified</h3>
212 <ul className="space-y-0.5">
213 {diff.modified.map((m) => (
214 <li key={m.id} className="flex gap-2 text-sm" data-testid={`version-diff-item-${m.id}`}>
215 <span className="shrink-0 rounded bg-muted px-1 text-[10px] text-muted-foreground">{m.fields.join(", ")}</span>
216 </li>
217 ))}
218 </ul>
219 </section>
220 )}
221 {diff.moved.length > 0 && (
222 <section className="mb-4">
223 <h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Moved</h3>
224 <ul className="space-y-0.5">
225 {diff.moved.map((m) => (
226 <li key={m.id} className="text-sm text-muted-foreground" data-testid={`version-diff-item-${m.id}`}>
227 {m.from} → {m.to}
228 </li>
229 ))}
230 </ul>
231 </section>
232 )}
233 </div>
234 );
235}
236
237function PreviewPane({
238 preview,
239 loading,
240 error
241}: {
242 preview: VersionPreview | undefined;
243 loading: boolean;
244 error: Error | null;
245}) {
246 if (loading) return <Loading />;
247 if (error) return <PaneError message={error.message} />;
248 if (!preview) return null;
249
250 const blocks = [...preview.snapshot.blocks].sort((a, b) => a.position.localeCompare(b.position));
251 return (
252 <div>
253 <p className="mb-3 text-xs text-muted-foreground" data-testid="version-preview-meta">
254 v{preview.version} · {preview.note ?? "no note"} · {new Date(preview.createdAt).toLocaleString()} ·{" "}
255 {blocks.length} blocks
256 </p>
257 <ul className="space-y-1" data-testid="version-preview-blocks">
258 {blocks.map((b) => (
259 <li key={b.id} className="text-sm" data-testid={`version-preview-block-${b.id}`}>
260 <span className="mr-2 rounded bg-muted px-1 text-[10px] text-muted-foreground">{b.type}</span>
261 {blockText(b.content) || <em className="text-muted-foreground">empty</em>}
262 </li>
263 ))}
264 </ul>
265 </div>
266 );
267}
268
269function Loading() {
270 return (
271 <p className="inline-flex items-center gap-2 py-4 text-sm text-muted-foreground">
272 <Loader2 className="size-4 animate-spin" aria-hidden /> Loading…
273 </p>
274 );
275}
276
277function PaneError({ message }: { message: string }) {
278 return <p className="rounded-md border border-dashed border-border p-4 text-sm text-muted-foreground">{message}</p>;
279}
280