import { Badge, Button, Loader, Toast } from "@cloudflare/kumo"; import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { ClockCounterClockwise, ArrowCounterClockwise, CaretDown, CaretUp, Plus, Minus, PencilSimple, } from "@phosphor-icons/react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { fetchRevisions, restoreRevision, type Revision } from "../lib/api"; import { formatRelativeTime } from "../lib/utils"; import { ConfirmDialog } from "./ConfirmDialog"; // ============================================================================= // Diff utilities // ============================================================================= type DiffKind = "added" | "removed" | "changed" | "unchanged"; interface FieldDiff { field: string; kind: DiffKind; oldValue?: unknown; newValue?: unknown; } /** * Compute field-level diff between two revision data snapshots. * `older` is the revision being viewed, `newer` is the next revision after it. */ function computeFieldDiff( older: Record, newer: Record, ): FieldDiff[] { const allKeys = new Set([...Object.keys(older), ...Object.keys(newer)]); const diffs: FieldDiff[] = []; for (const key of allKeys) { const inOlder = key in older; const inNewer = key in newer; if (inOlder && !inNewer) { diffs.push({ field: key, kind: "removed", oldValue: older[key] }); } else if (!inOlder && inNewer) { diffs.push({ field: key, kind: "added", newValue: newer[key] }); } else { const oldJson = JSON.stringify(older[key]); const newJson = JSON.stringify(newer[key]); if (oldJson !== newJson) { diffs.push({ field: key, kind: "changed", oldValue: older[key], newValue: newer[key] }); } else { diffs.push({ field: key, kind: "unchanged", oldValue: older[key], newValue: newer[key] }); } } } // Sort: changes first, then added, removed, unchanged const kindOrder: Record = { changed: 0, added: 1, removed: 2, unchanged: 3 }; diffs.sort((a, b) => kindOrder[a.kind] - kindOrder[b.kind]); return diffs; } /** Format a value for display in the diff view */ function formatDiffValue(value: unknown): string { if (value === null || value === undefined) return "—"; if (typeof value === "string") return value; return JSON.stringify(value, null, 2); } interface RevisionHistoryProps { collection: string; entryId: string; /** Called when a revision is successfully restored */ onRestored?: () => void; } /** * Format a date as a full timestamp */ function formatFullDate(dateString: string): string { return new Date(dateString).toLocaleString(undefined, { weekday: "short", year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } /** * RevisionHistory component - displays revision history for a content item * with ability to restore previous versions. */ export function RevisionHistory({ collection, entryId, onRestored }: RevisionHistoryProps) { const { t } = useLingui(); const [isExpanded, setIsExpanded] = React.useState(false); const [selectedRevision, setSelectedRevision] = React.useState(null); const [restoreTarget, setRestoreTarget] = React.useState(null); const queryClient = useQueryClient(); const toastManager = Toast.useToastManager(); const { data, isLoading, error } = useQuery({ queryKey: ["revisions", collection, entryId], queryFn: () => fetchRevisions(collection, entryId, { limit: 20 }), enabled: isExpanded, // Only fetch when expanded }); const restoreMutation = useMutation({ mutationFn: (revisionId: string) => restoreRevision(revisionId), onSuccess: () => { // Invalidate content and revisions queries void queryClient.invalidateQueries({ queryKey: ["content", collection, entryId], }); void queryClient.invalidateQueries({ queryKey: ["revisions", collection, entryId], }); setSelectedRevision(null); setRestoreTarget(null); onRestored?.(); toastManager.add({ title: t`Revision restored`, description: t`Content has been updated to the selected revision.`, }); }, onError: (err: Error) => { toastManager.add({ title: t`Restore failed`, description: err.message, type: "error", }); }, }); const handleRestore = (revision: Revision) => { setRestoreTarget(revision); }; const revisions = data?.items ?? []; const total = data?.total ?? 0; return ( <>
{/* Header - always visible */} {/* Content - shown when expanded */} {isExpanded && (
{isLoading ? (
) : error ? (
{t`Failed to load revisions`}
) : revisions.length === 0 ? (
{t`No revisions yet`}
) : (
{revisions.map((revision, index) => ( 0 ? revisions[index - 1] : undefined} isLatest={index === 0} isRestoring={ restoreMutation.isPending && restoreMutation.variables === revision.id } onRestore={() => handleRestore(revision)} onSelect={() => setSelectedRevision(selectedRevision?.id === revision.id ? null : revision) } isSelected={selectedRevision?.id === revision.id} /> ))}
)}
)}
{ setRestoreTarget(null); restoreMutation.reset(); }} title={t`Restore Revision?`} description={ restoreTarget ? t`Restore this version from ${formatFullDate(restoreTarget.createdAt)}? This will update the current content to this revision's data.` : "" } confirmLabel={t`Restore`} pendingLabel={t`Restoring...`} variant="primary" isPending={restoreMutation.isPending} error={restoreMutation.error} onConfirm={() => { if (restoreTarget) restoreMutation.mutate(restoreTarget.id); }} /> ); } interface RevisionItemProps { revision: Revision; /** The next newer revision to compare against (undefined for the latest) */ compareRevision?: Revision; isLatest: boolean; isRestoring: boolean; isSelected: boolean; onRestore: () => void; onSelect: () => void; } function RevisionItem({ revision, compareRevision, isLatest, isRestoring, isSelected, onRestore, onSelect, }: RevisionItemProps) { const { t } = useLingui(); return (
{!isLatest && ( )}
{/* Diff view or snapshot - shown when selected */} {isSelected && (
{compareRevision ? ( ) : ( <>
{t`Content snapshot:`}
								{JSON.stringify(revision.data, null, 2)}
							
)}
)}
); } // ============================================================================= // Diff view component // ============================================================================= interface RevisionDiffViewProps { older: Record; newer: Record; } function RevisionDiffView({ older, newer }: RevisionDiffViewProps) { const { t } = useLingui(); const [showUnchanged, setShowUnchanged] = React.useState(false); const diffs = React.useMemo(() => computeFieldDiff(older, newer), [older, newer]); const changedCount = diffs.filter((d) => d.kind !== "unchanged").length; const unchangedCount = diffs.length - changedCount; if (diffs.length === 0) { return (
{t`No fields to compare`}
); } const visibleDiffs = showUnchanged ? diffs : diffs.filter((d) => d.kind !== "unchanged"); return (
{plural(changedCount, { one: "# change from next revision", other: "# changes from next revision", })}
{unchangedCount > 0 && ( )}
{visibleDiffs.map((diff) => ( ))}
); } const DIFF_STYLES: Record = { added: { bg: "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800", icon: