1"use client";
2
3
8import { useState } from "react";
9import { Camera, Clock, RotateCcw } from "lucide-react";
10import { useQueryClient } from "@tanstack/react-query";
11import { pageApi, isApiError } from "@/lib/api";
12import { formatRelativeTime } from "@/lib/utils";
13import { toast } from "@/stores/ui-store";
14import { ConfirmDialog } from "@/components/ui/confirm-dialog";
15import type { PageVersion } from "@/lib/types";
16
17export interface HistoryPopoverProps {
18 versions: PageVersion[];
19 isLoading: boolean;
20 pageId: string;
21 onClose: () => void;
22
23 onCompare?: (v: PageVersion) => void;
24
26 canEdit?: boolean;
27}
28
29export function HistoryPopover({ versions, isLoading, pageId, onClose, onCompare, canEdit }: HistoryPopoverProps) {
30 const [target, setTarget] = useState<PageVersion | null>(null);
31 const [restoring, setRestoring] = useState(false);
32 const [snapping, setSnapping] = useState(false);
33 const qc = useQueryClient();
34
35
36
37
38 const takeSnapshot = async () => {
39 if (snapping) return;
40 setSnapping(true);
41 try {
42 await pageApi.snapshotVersion(pageId);
43 qc.invalidateQueries({ queryKey: ["pages", pageId, "versions"] });
44 toast("success", "Snapshot captured");
45 } catch (err) {
46 toast("error", "Snapshot failed", isApiError(err) ? err.message : err instanceof Error ? err.message : String(err));
47 } finally {
48 setSnapping(false);
49 }
50 };
51
52 const doRestore = async () => {
53 if (!target) return;
54 setRestoring(true);
55 try {
56 await pageApi.restoreVersion(pageId, target.id);
57 toast("success", "Version restored", `Restored to ${target.createdAt}`);
58 setTarget(null);
59 onClose();
60 } catch (err) {
61 toast("error", "Restore failed", err instanceof Error ? err.message : undefined);
62 } finally {
63 setRestoring(false);
64 }
65 };
66
67 return (
68 <>
69 <div
70 role="menu"
71 aria-label="Page history"
72 data-testid="page-versions"
73 className="absolute right-0 z-50 mt-1 w-72 rounded-md border border-border bg-popover p-2 shadow-xl"
74 >
75 <div className="mb-1 flex items-center justify-between">
76 <p className="px-1 pb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
77 Page history
78 </p>
79 {canEdit ? (
80 <button
81 type="button"
82 data-testid="version-snapshot-button"
83 onClick={() => void takeSnapshot()}
84 disabled={snapping}
85 className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-xs text-foreground/80 hover:bg-accent disabled:opacity-50"
86 >
87 <Camera className="size-3.5" aria-hidden /> {snapping ? "…" : "Snapshot now"}
88 </button>
89 ) : null}
90 </div>
91 {isLoading ? (
92 <p className="px-1 py-2 text-sm text-muted-foreground">Loading…</p>
93 ) : versions.length === 0 ? (
94 <p className="px-1 py-2 text-sm text-muted-foreground">
95 No versions yet — take a snapshot.
96 </p>
97 ) : (
98 <ul className="max-h-72 space-y-0.5 overflow-y-auto">
99 {versions.slice(0, 20).map((v) => (
100 <li key={v.id} data-testid="page-version-entry">
101 <div className="flex w-full items-center gap-1">
102 <button
103 type="button"
104 role="menuitem"
105 onClick={() => setTarget(v)}
106 className="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
107 >
108 <Clock className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
109 <span className="min-w-0 flex-1">
110 <span className="block truncate font-medium">v{v.version}{v.note ? ` — ${v.note}` : ""}</span>
111 <span className="block text-xs text-muted-foreground">
112 {formatRelativeTime(v.createdAt)}
113 </span>
114 </span>
115 <RotateCcw className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
116 </button>
117 <button
118 type="button"
119 data-testid={`version-compare-${v.id}`}
120 onClick={() => onCompare?.(v)}
121 className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-accent"
122 title="Compare & preview this version"
123 >
124 Compare
125 </button>
126 </div>
127 </li>
128 ))}
129 </ul>
130 )}
131 </div>
132
133 <ConfirmDialog
134 open={Boolean(target)}
135 title="Restore this version?"
136 description={
137 target
138 ? `The current content will be replaced by the version from ${new Date(target.createdAt).toLocaleString()}. This cannot be undone.`
139 : undefined
140 }
141 confirmLabel="Restore"
142 destructive
143 busy={restoring}
144 onConfirm={() => void doRestore()}
145 onClose={() => setTarget(null)}
146 />
147 </>
148 );
149}
150