create-nextblock 0.2.59 → 0.2.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +231 -77
- package/templates/nextblock-template/app/cms/revisions/actions.ts +213 -12
- package/templates/nextblock-template/app/cms/revisions/service.ts +174 -50
- package/templates/nextblock-template/next-env.d.ts +1 -1
- package/templates/nextblock-template/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// apps/nextblock/app/cms/revisions/RevisionHistoryButton.tsx
|
|
2
2
|
"use client";
|
|
3
3
|
|
|
4
|
-
import { useEffect, useState, useTransition } from 'react';
|
|
4
|
+
import { useEffect, useState, useTransition, Fragment } from 'react';
|
|
5
5
|
import { Button } from "@nextblock-cms/ui";
|
|
6
|
+
import { cn } from "@nextblock-cms/utils";
|
|
6
7
|
import { Spinner, Alert, AlertDescription } from "@nextblock-cms/ui";
|
|
7
8
|
import {
|
|
8
9
|
Dialog,
|
|
@@ -33,49 +34,91 @@ type RevisionItem = {
|
|
|
33
34
|
author?: { full_name?: string | null; username?: string | null } | null;
|
|
34
35
|
};
|
|
35
36
|
|
|
37
|
+
import { Input } from "@nextblock-cms/ui";
|
|
38
|
+
|
|
36
39
|
export default function RevisionHistoryButton({ parentType, parentId }: RevisionHistoryButtonProps) {
|
|
37
40
|
const [open, setOpen] = useState(false);
|
|
38
41
|
const [loading, setLoading] = useState(false);
|
|
39
|
-
const [revisions, setRevisions] = useState<RevisionItem[]
|
|
42
|
+
const [revisions, setRevisions] = useState<RevisionItem[]>([]);
|
|
40
43
|
const [currentVersion, setCurrentVersion] = useState<number | null>(null);
|
|
41
44
|
const [error, setError] = useState<string | null>(null);
|
|
42
45
|
const [message, setMessage] = useState<string | null>(null);
|
|
43
46
|
const [isPending, startTransition] = useTransition();
|
|
47
|
+
const [page, setPage] = useState(1);
|
|
48
|
+
const [startDate, setStartDate] = useState('');
|
|
49
|
+
const [endDate, setEndDate] = useState('');
|
|
50
|
+
|
|
51
|
+
const [totalPages, setTotalPages] = useState(0);
|
|
52
|
+
|
|
44
53
|
const router = useRouter();
|
|
45
|
-
|
|
46
54
|
const [compareLoading, setCompareLoading] = useState(false);
|
|
47
55
|
const [activeCompareVersion, setActiveCompareVersion] = useState<number | null>(null);
|
|
48
56
|
const [leftText, setLeftText] = useState<string | null>(null);
|
|
49
57
|
const [rightText, setRightText] = useState<string | null>(null);
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
|
|
59
|
+
const loadRevisions = async (pageToLoad: number, start?: string, end?: string) => {
|
|
52
60
|
setLoading(true);
|
|
53
61
|
setError(null);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
setCurrentVersion(null);
|
|
62
|
-
} else {
|
|
63
|
-
setRevisions(res.revisions as unknown as RevisionItem[]);
|
|
64
|
-
setCurrentVersion((res as any).currentVersion ?? null);
|
|
65
|
-
}
|
|
66
|
-
} else {
|
|
67
|
-
const res = await listPostRevisions(parentId);
|
|
68
|
-
if ('error' in res) { setError(res.error ?? 'Unknown error'); setRevisions(null); setCurrentVersion(null); }
|
|
69
|
-
else { setRevisions(res.revisions as unknown as RevisionItem[]); setCurrentVersion((res as any).currentVersion ?? null); }
|
|
70
|
-
}
|
|
71
|
-
} catch (e: unknown) {
|
|
72
|
-
setError(e instanceof Error ? e.message : 'Failed to load revisions');
|
|
73
|
-
} finally {
|
|
74
|
-
setLoading(false);
|
|
62
|
+
try {
|
|
63
|
+
let res;
|
|
64
|
+
// Pass date range to actions
|
|
65
|
+
if (parentType === 'page') {
|
|
66
|
+
res = await listPageRevisions(parentId, pageToLoad, start, end);
|
|
67
|
+
} else {
|
|
68
|
+
res = await listPostRevisions(parentId, pageToLoad, start, end);
|
|
75
69
|
}
|
|
76
|
-
|
|
70
|
+
|
|
71
|
+
if ('error' in res) {
|
|
72
|
+
setError(res.error ?? 'Unknown error');
|
|
73
|
+
setRevisions([]);
|
|
74
|
+
} else {
|
|
75
|
+
const newRevisions = res.revisions as unknown as RevisionItem[];
|
|
76
|
+
setRevisions(newRevisions);
|
|
77
|
+
setCurrentVersion((res as any).currentVersion ?? null);
|
|
78
|
+
setTotalPages((res as any).totalPages ?? 0);
|
|
79
|
+
}
|
|
80
|
+
} catch (e: unknown) {
|
|
81
|
+
setError(e instanceof Error ? e.message : 'Failed to load revisions');
|
|
82
|
+
} finally {
|
|
83
|
+
setLoading(false);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!open) return;
|
|
89
|
+
setRevisions([]);
|
|
90
|
+
setPage(1);
|
|
91
|
+
setStartDate('');
|
|
92
|
+
setEndDate('');
|
|
93
|
+
loadRevisions(1, '', '');
|
|
77
94
|
}, [open, parentId, parentType]);
|
|
78
95
|
|
|
96
|
+
const handlePageChange = (newPage: number) => {
|
|
97
|
+
setPage(newPage);
|
|
98
|
+
loadRevisions(newPage, startDate, endDate);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
102
|
+
const newVal = e.target.value;
|
|
103
|
+
setStartDate(newVal);
|
|
104
|
+
setPage(1);
|
|
105
|
+
loadRevisions(1, newVal, endDate);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
109
|
+
const newVal = e.target.value;
|
|
110
|
+
setEndDate(newVal);
|
|
111
|
+
setPage(1);
|
|
112
|
+
loadRevisions(1, startDate, newVal);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const handleClearDates = () => {
|
|
116
|
+
setStartDate('');
|
|
117
|
+
setEndDate('');
|
|
118
|
+
setPage(1);
|
|
119
|
+
loadRevisions(1, '', '');
|
|
120
|
+
};
|
|
121
|
+
|
|
79
122
|
const handleRestore = (version: number) => {
|
|
80
123
|
setMessage(null);
|
|
81
124
|
setError(null);
|
|
@@ -91,9 +134,7 @@ export default function RevisionHistoryButton({ parentType, parentId }: Revision
|
|
|
91
134
|
}
|
|
92
135
|
setMessage('Version restored successfully.');
|
|
93
136
|
toast.success('Version restored successfully');
|
|
94
|
-
// refresh current page to fetch restored content
|
|
95
137
|
router.refresh();
|
|
96
|
-
// Close the dialog after a short delay
|
|
97
138
|
setTimeout(() => setOpen(false), 800);
|
|
98
139
|
} catch (e: unknown) {
|
|
99
140
|
setError(e instanceof Error ? e.message : 'Failed to restore version');
|
|
@@ -132,19 +173,41 @@ export default function RevisionHistoryButton({ parentType, parentId }: Revision
|
|
|
132
173
|
return (
|
|
133
174
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
134
175
|
<Button variant="outline" onClick={() => setOpen(true)}>Revision History</Button>
|
|
135
|
-
<DialogContent
|
|
176
|
+
<DialogContent
|
|
177
|
+
className="max-w-2xl h-[95vh] overflow-y-auto flex flex-col"
|
|
178
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
179
|
+
>
|
|
136
180
|
<DialogHeader>
|
|
137
|
-
<
|
|
181
|
+
<div className="flex items-center justify-between mr-8">
|
|
182
|
+
<DialogTitle>Revision History</DialogTitle>
|
|
183
|
+
<div className="flex items-center gap-2">
|
|
184
|
+
<span className="text-sm text-muted-foreground whitespace-nowrap">From:</span>
|
|
185
|
+
<Input
|
|
186
|
+
type="date"
|
|
187
|
+
className="w-auto h-8 text-sm px-2"
|
|
188
|
+
value={startDate}
|
|
189
|
+
onChange={handleStartDateChange}
|
|
190
|
+
/>
|
|
191
|
+
<span className="text-sm text-muted-foreground whitespace-nowrap">To:</span>
|
|
192
|
+
<Input
|
|
193
|
+
type="date"
|
|
194
|
+
className="w-auto h-8 text-sm px-2"
|
|
195
|
+
value={endDate}
|
|
196
|
+
onChange={handleEndDateChange}
|
|
197
|
+
/>
|
|
198
|
+
{(startDate || endDate) && (
|
|
199
|
+
<Button variant="ghost" size="sm" onClick={handleClearDates} className="h-8 px-2 text-xs text-muted-foreground hover:text-foreground">
|
|
200
|
+
Clear
|
|
201
|
+
</Button>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
138
205
|
<DialogDescription>
|
|
139
206
|
Browse and restore previous versions.
|
|
140
207
|
</DialogDescription>
|
|
141
208
|
</DialogHeader>
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
<div className="flex items-center justify-center py-4">
|
|
145
|
-
<Spinner size="lg" />
|
|
146
|
-
</div>
|
|
147
|
-
)}
|
|
209
|
+
|
|
210
|
+
<div className="flex-1 overflow-y-auto space-y-3 p-1">
|
|
148
211
|
{error && (
|
|
149
212
|
<Alert variant="destructive">
|
|
150
213
|
<AlertDescription>{error}</AlertDescription>
|
|
@@ -156,55 +219,146 @@ export default function RevisionHistoryButton({ parentType, parentId }: Revision
|
|
|
156
219
|
</Alert>
|
|
157
220
|
)}
|
|
158
221
|
|
|
159
|
-
{
|
|
160
|
-
<div className="
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
{revisions && revisions.length > 0 && (
|
|
164
|
-
<div className="divide-y rounded border">
|
|
165
|
-
{revisions.map((rev: RevisionItem) => {
|
|
222
|
+
{revisions && revisions.length > 0 ? (
|
|
223
|
+
<div className="rounded border divide-y">
|
|
224
|
+
{revisions.map((rev: RevisionItem, idx) => {
|
|
166
225
|
const when = rev.created_at ? formatDistanceToNow(new Date(rev.created_at), { addSuffix: true }) : '';
|
|
167
|
-
const
|
|
226
|
+
const authorName = rev.author?.full_name || rev.author?.username;
|
|
227
|
+
const isCurrent = currentVersion != null && rev.version === currentVersion;
|
|
228
|
+
const isInitial = rev.version === 1;
|
|
229
|
+
|
|
168
230
|
return (
|
|
169
|
-
<
|
|
170
|
-
<div className="
|
|
171
|
-
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
231
|
+
<Fragment key={`${rev.id}-${idx}`}>
|
|
232
|
+
<div className={cn("flex items-center justify-between gap-3 p-3 text-sm", activeCompareVersion === rev.version && "bg-muted/50")}>
|
|
233
|
+
<div className="flex flex-col gap-0.5">
|
|
234
|
+
<div className="flex items-center gap-2">
|
|
235
|
+
<span className="font-semibold text-gray-900">
|
|
236
|
+
{isCurrent ? 'Current Version' : (isInitial ? 'Initial Version' : `Version ${rev.version}`)}
|
|
237
|
+
</span>
|
|
238
|
+
<span className="text-muted-foreground text-xs">• {when}</span>
|
|
239
|
+
{authorName && <span className="text-muted-foreground text-xs">• by {authorName}</span>}
|
|
240
|
+
{!isCurrent && (
|
|
241
|
+
<span className="text-[10px] text-muted-foreground bg-gray-100 px-1 rounded uppercase tracking-wider">{rev.revision_type}</span>
|
|
242
|
+
)}
|
|
243
|
+
{isInitial && !isCurrent && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 border border-gray-200">v1</span>}
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
180
247
|
<div className="flex gap-2">
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
248
|
+
{!isCurrent && (
|
|
249
|
+
<>
|
|
250
|
+
<Button
|
|
251
|
+
variant="outline"
|
|
252
|
+
size="sm"
|
|
253
|
+
onClick={() => handleCompare(rev.version)}
|
|
254
|
+
disabled={compareLoading && activeCompareVersion === rev.version || (rev as any).has_changes === false}
|
|
255
|
+
className={cn("h-8 px-2 text-xs text-blue-600 border-blue-200 hover:bg-blue-50 hover:text-blue-700", (rev as any).has_changes === false && "invisible pointer-events-none")}
|
|
256
|
+
>
|
|
257
|
+
{compareLoading && activeCompareVersion === rev.version ? <><Spinner className="mr-1 h-3 w-3" /> compare</> : 'Compare'}
|
|
258
|
+
</Button>
|
|
259
|
+
<Button
|
|
260
|
+
variant="destructive"
|
|
261
|
+
size="sm"
|
|
262
|
+
onClick={() => handleRestore(rev.version)}
|
|
263
|
+
disabled={isPending || (rev as any).has_changes === false}
|
|
264
|
+
className={cn("h-8 px-2 text-xs", (rev as any).has_changes === false && "invisible pointer-events-none")}
|
|
265
|
+
>
|
|
266
|
+
{isPending ? <Spinner className="h-3 w-3" /> : 'Restore'}
|
|
267
|
+
</Button>
|
|
268
|
+
</>
|
|
269
|
+
)}
|
|
270
|
+
{isCurrent && <span className="text-xs text-muted-foreground italic px-2 self-center">Active</span>}
|
|
187
271
|
</div>
|
|
188
272
|
</div>
|
|
273
|
+
|
|
274
|
+
{activeCompareVersion === rev.version && (
|
|
275
|
+
<div className="p-3 bg-slate-50 border-b relative">
|
|
276
|
+
<div className="mb-2 flex items-center justify-between">
|
|
277
|
+
<div className="font-semibold text-sm">Comparing Version {activeCompareVersion} to Current</div>
|
|
278
|
+
<Button variant="outline" size="sm" onClick={() => { setActiveCompareVersion(null); setLeftText(null); setRightText(null); }} className="h-7 text-xs">Close Compare</Button>
|
|
279
|
+
</div>
|
|
280
|
+
{compareLoading && <div className="text-sm text-muted-foreground py-4 text-center">Preparing diff…</div>}
|
|
281
|
+
{!compareLoading && leftText && rightText && (
|
|
282
|
+
<div className="max-h-[50vh] overflow-y-auto border rounded bg-white">
|
|
283
|
+
<div className="p-2">
|
|
284
|
+
<JsonDiffView
|
|
285
|
+
oldValue={leftText}
|
|
286
|
+
newValue={rightText}
|
|
287
|
+
leftTitle="Current (Now)"
|
|
288
|
+
rightTitle={`Version ${activeCompareVersion}`}
|
|
289
|
+
/>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
</Fragment>
|
|
189
296
|
);
|
|
190
297
|
})}
|
|
191
298
|
</div>
|
|
299
|
+
) : (!loading && <div className="text-sm text-muted-foreground text-center py-4">No revisions found for this selection.</div>)}
|
|
300
|
+
|
|
301
|
+
{loading && (
|
|
302
|
+
<div className="flex items-center justify-center py-2">
|
|
303
|
+
<Spinner size="lg" />
|
|
304
|
+
</div>
|
|
192
305
|
)}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
306
|
+
|
|
307
|
+
{!loading && (revisions.length > 0 || page > 1) && (
|
|
308
|
+
<div className="flex items-center justify-center gap-2 py-2 flex-wrap">
|
|
309
|
+
<Button
|
|
310
|
+
variant="outline"
|
|
311
|
+
size="sm"
|
|
312
|
+
onClick={() => handlePageChange(page - 1)}
|
|
313
|
+
disabled={page <= 1}
|
|
314
|
+
className="h-8 px-2"
|
|
315
|
+
>
|
|
316
|
+
Previous
|
|
317
|
+
</Button>
|
|
318
|
+
|
|
319
|
+
{totalPages > 0 && Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => {
|
|
320
|
+
// Show limited pages if too many
|
|
321
|
+
if (totalPages > 10) {
|
|
322
|
+
if (p === 1 || p === totalPages || (p >= page - 2 && p <= page + 2)) {
|
|
323
|
+
return (
|
|
324
|
+
<Button
|
|
325
|
+
key={p}
|
|
326
|
+
variant={p === page ? "default" : "outline"}
|
|
327
|
+
size="sm"
|
|
328
|
+
onClick={() => handlePageChange(p)}
|
|
329
|
+
className="h-8 w-8 p-0"
|
|
330
|
+
>
|
|
331
|
+
{p}
|
|
332
|
+
</Button>
|
|
333
|
+
);
|
|
334
|
+
} else if (p === page - 3 || p === page + 3) {
|
|
335
|
+
return <span key={p} className="text-muted-foreground px-1">...</span>;
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return (
|
|
341
|
+
<Button
|
|
342
|
+
key={p}
|
|
343
|
+
variant={p === page ? "default" : "outline"}
|
|
344
|
+
size="sm"
|
|
345
|
+
onClick={() => handlePageChange(p)}
|
|
346
|
+
className="h-8 w-8 p-0"
|
|
347
|
+
>
|
|
348
|
+
{p}
|
|
349
|
+
</Button>
|
|
350
|
+
);
|
|
351
|
+
})}
|
|
352
|
+
|
|
353
|
+
<Button
|
|
354
|
+
variant="outline"
|
|
355
|
+
size="sm"
|
|
356
|
+
onClick={() => handlePageChange(page + 1)}
|
|
357
|
+
disabled={page >= totalPages}
|
|
358
|
+
className="h-8 px-2"
|
|
359
|
+
>
|
|
360
|
+
Next
|
|
361
|
+
</Button>
|
|
208
362
|
</div>
|
|
209
363
|
)}
|
|
210
364
|
</div>
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { createClient } from "@nextblock-cms/db/server";
|
|
5
5
|
import { restorePageToVersion, restorePostToVersion, reconstructPageVersionContent, reconstructPostVersionContent } from './service';
|
|
6
6
|
import { getFullPageContent, getFullPostContent } from './utils';
|
|
7
|
+
import { compare } from 'fast-json-patch';
|
|
7
8
|
|
|
8
9
|
type RevisionListItem = {
|
|
9
10
|
id: number;
|
|
@@ -14,30 +15,230 @@ type RevisionListItem = {
|
|
|
14
15
|
author?: { full_name?: string | null; username?: string | null } | null;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
const REVISIONS_PER_PAGE = 10; // Reduced to 10 as requested
|
|
19
|
+
|
|
20
|
+
export async function listPageRevisions(pageId: number, page = 1, startDate?: string, endDate?: string) {
|
|
18
21
|
const supabase = createClient();
|
|
19
|
-
|
|
22
|
+
|
|
23
|
+
let query = supabase
|
|
20
24
|
.from('page_revisions')
|
|
21
|
-
.select('id, page_id, author_id, version, revision_type, created_at, author:profiles(full_name, username)')
|
|
25
|
+
.select('id, page_id, author_id, version, revision_type, created_at, content, author:profiles(full_name, username)', { count: 'exact' })
|
|
22
26
|
.eq('page_id', pageId)
|
|
23
|
-
.order('version', { ascending: false })
|
|
27
|
+
.order('version', { ascending: false })
|
|
28
|
+
.limit(1000); // Fetch up to 1000 recent revisions to allow dense pagination after filtering
|
|
29
|
+
|
|
30
|
+
if (startDate) {
|
|
31
|
+
query = query.gte('created_at', `${startDate}T00:00:00.000Z`);
|
|
32
|
+
}
|
|
33
|
+
if (endDate) {
|
|
34
|
+
query = query.lte('created_at', `${endDate}T23:59:59.999Z`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { data, error } = await query; // count is less useful now as we filter
|
|
38
|
+
|
|
24
39
|
if (error) return { error: error.message } as const;
|
|
25
|
-
|
|
40
|
+
|
|
41
|
+
const { data: pageRow } = await supabase.from('pages').select('version, created_at').eq('id', pageId).single();
|
|
26
42
|
const currentVersion = pageRow?.version ?? null;
|
|
27
|
-
|
|
43
|
+
const currentContent = await getFullPageContent(pageId);
|
|
44
|
+
|
|
45
|
+
// Map data to include useful flags instead of filtering
|
|
46
|
+
const allRevisions = (data || []).map((r: any) => {
|
|
47
|
+
let has_changes = true;
|
|
48
|
+
|
|
49
|
+
// 1. Check Diffs
|
|
50
|
+
if (r.revision_type === 'diff') {
|
|
51
|
+
let ops = r.content;
|
|
52
|
+
if (typeof ops === 'string') {
|
|
53
|
+
try { ops = JSON.parse(ops); } catch { ops = []; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Strict check: Diff must be a non-empty array of operations
|
|
57
|
+
if (!Array.isArray(ops) || ops.length === 0) {
|
|
58
|
+
has_changes = false;
|
|
59
|
+
} else {
|
|
60
|
+
// Check metadata/variant
|
|
61
|
+
const ignoredSuffixes = ['/updated_at', '/created_at', '/last_modified', '/modified_at', '/version', '/id', '/published_at', '/author_id', '/variant'];
|
|
62
|
+
const usefulOps = ops.filter((op: any) => {
|
|
63
|
+
if (!op.path) return true;
|
|
64
|
+
return !ignoredSuffixes.some(suffix => op.path.endsWith(suffix));
|
|
65
|
+
});
|
|
66
|
+
if (usefulOps.length === 0) has_changes = false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 2. Check Snapshots
|
|
71
|
+
if (r.revision_type === 'snapshot' && currentContent && r.version !== currentVersion) {
|
|
72
|
+
let snapshotContent = r.content;
|
|
73
|
+
if (typeof snapshotContent === 'string') {
|
|
74
|
+
try { snapshotContent = JSON.parse(snapshotContent); } catch { /* default true */ }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ops = compare(currentContent, snapshotContent);
|
|
78
|
+
|
|
79
|
+
const ignoredSuffixes = ['/updated_at', '/created_at', '/last_modified', '/modified_at', '/version', '/id', '/published_at', '/author_id', '/variant'];
|
|
80
|
+
const usefulOps = ops.filter((op: any) => {
|
|
81
|
+
if (!op.path) return true;
|
|
82
|
+
return !ignoredSuffixes.some(suffix => op.path.endsWith(suffix));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (usefulOps.length === 0) has_changes = false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { ...r, has_changes };
|
|
89
|
+
}) as (RevisionListItem & { has_changes: boolean; content: any })[];
|
|
90
|
+
|
|
91
|
+
// Append Synthetic V1
|
|
92
|
+
const hasVersion1 = allRevisions.some(r => r.version === 1);
|
|
93
|
+
const pageCreatedAt = pageRow?.created_at;
|
|
94
|
+
|
|
95
|
+
let matchesDate = true;
|
|
96
|
+
if (pageCreatedAt) {
|
|
97
|
+
if (startDate && pageCreatedAt < `${startDate}T00:00:00.000Z`) matchesDate = false;
|
|
98
|
+
if (endDate && pageCreatedAt > `${endDate}T23:59:59.999Z`) matchesDate = false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!hasVersion1 && (currentVersion ?? 0) > 1 && matchesDate) {
|
|
102
|
+
allRevisions.push({
|
|
103
|
+
id: -1,
|
|
104
|
+
version: 1,
|
|
105
|
+
revision_type: 'snapshot',
|
|
106
|
+
created_at: pageCreatedAt ?? new Date().toISOString(),
|
|
107
|
+
author_id: null,
|
|
108
|
+
author: { full_name: 'System (Initial)', username: 'system' },
|
|
109
|
+
has_changes: true, // V1 always counts
|
|
110
|
+
content: null
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
allRevisions.sort((a, b) => b.version - a.version);
|
|
115
|
+
|
|
116
|
+
const totalFiltered = allRevisions.length;
|
|
117
|
+
const totalPages = Math.ceil(totalFiltered / REVISIONS_PER_PAGE);
|
|
118
|
+
|
|
119
|
+
const fromIndex = (page - 1) * REVISIONS_PER_PAGE;
|
|
120
|
+
// Use map to strip heavy content if not needed, but keep flags
|
|
121
|
+
const slicedRevisions = allRevisions
|
|
122
|
+
.slice(fromIndex, fromIndex + REVISIONS_PER_PAGE)
|
|
123
|
+
.map(({ content, ...rest }) => rest);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
success: true as const,
|
|
127
|
+
revisions: slicedRevisions,
|
|
128
|
+
currentVersion,
|
|
129
|
+
count: totalFiltered,
|
|
130
|
+
totalPages,
|
|
131
|
+
hasMore: (page * REVISIONS_PER_PAGE) < totalFiltered
|
|
132
|
+
};
|
|
28
133
|
}
|
|
29
134
|
|
|
30
|
-
export async function listPostRevisions(postId: number) {
|
|
135
|
+
export async function listPostRevisions(postId: number, page = 1, startDate?: string, endDate?: string) {
|
|
31
136
|
const supabase = createClient();
|
|
32
|
-
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
let query = supabase
|
|
33
141
|
.from('post_revisions')
|
|
34
|
-
.select('id, post_id, author_id, version, revision_type, created_at, author:profiles(full_name, username)')
|
|
142
|
+
.select('id, post_id, author_id, version, revision_type, created_at, content, author:profiles(full_name, username)', { count: 'exact' })
|
|
35
143
|
.eq('post_id', postId)
|
|
36
|
-
.order('version', { ascending: false })
|
|
144
|
+
.order('version', { ascending: false })
|
|
145
|
+
.limit(1000);
|
|
146
|
+
|
|
147
|
+
if (startDate) {
|
|
148
|
+
query = query.gte('created_at', `${startDate}T00:00:00.000Z`);
|
|
149
|
+
}
|
|
150
|
+
if (endDate) {
|
|
151
|
+
query = query.lte('created_at', `${endDate}T23:59:59.999Z`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const { data, error } = await query;
|
|
155
|
+
|
|
37
156
|
if (error) return { error: error.message } as const;
|
|
38
|
-
|
|
157
|
+
|
|
158
|
+
const { data: postRow } = await supabase.from('posts').select('version, created_at').eq('id', postId).single();
|
|
39
159
|
const currentVersion = postRow?.version ?? null;
|
|
40
|
-
|
|
160
|
+
const currentContent = await getFullPostContent(postId);
|
|
161
|
+
|
|
162
|
+
const allRevisions = (data || []).map((r: any) => {
|
|
163
|
+
let has_changes = true;
|
|
164
|
+
|
|
165
|
+
// 1. Check Diffs
|
|
166
|
+
if (r.revision_type === 'diff') {
|
|
167
|
+
let ops = r.content;
|
|
168
|
+
if (typeof ops === 'string') {
|
|
169
|
+
try { ops = JSON.parse(ops); } catch { ops = []; }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Strict check: Diff must be a non-empty array of operations
|
|
173
|
+
if (!Array.isArray(ops) || ops.length === 0) {
|
|
174
|
+
has_changes = false;
|
|
175
|
+
} else {
|
|
176
|
+
// Check metadata/variant
|
|
177
|
+
const ignoredSuffixes = ['/updated_at', '/created_at', '/last_modified', '/modified_at', '/version', '/id', '/published_at', '/author_id', '/variant'];
|
|
178
|
+
const usefulOps = ops.filter((op: any) => {
|
|
179
|
+
if (!op.path) return true;
|
|
180
|
+
return !ignoredSuffixes.some(suffix => op.path.endsWith(suffix));
|
|
181
|
+
});
|
|
182
|
+
if (usefulOps.length === 0) has_changes = false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 2. Check Snapshots
|
|
187
|
+
if (r.revision_type === 'snapshot' && currentContent && r.version !== currentVersion) {
|
|
188
|
+
let snapshotContent = r.content;
|
|
189
|
+
if (typeof snapshotContent === 'string') {
|
|
190
|
+
try { snapshotContent = JSON.parse(snapshotContent); } catch { /* default true */ }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const ops = compare(currentContent, snapshotContent);
|
|
194
|
+
|
|
195
|
+
const ignoredSuffixes = ['/updated_at', '/created_at', '/last_modified', '/modified_at', '/version', '/id', '/published_at', '/author_id', '/variant'];
|
|
196
|
+
const usefulOps = ops.filter((op: any) => {
|
|
197
|
+
if (!op.path) return true;
|
|
198
|
+
return !ignoredSuffixes.some(suffix => op.path.endsWith(suffix));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
if (usefulOps.length === 0) has_changes = false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { ...r, has_changes };
|
|
205
|
+
}) as (RevisionListItem & { has_changes: boolean; content: any })[];
|
|
206
|
+
|
|
207
|
+
// Synthetic V1
|
|
208
|
+
const hasVersion1 = allRevisions.some(r => r.version === 1);
|
|
209
|
+
const postCreatedAt = postRow?.created_at;
|
|
210
|
+
|
|
211
|
+
let matchesDate = true;
|
|
212
|
+
if (postCreatedAt) {
|
|
213
|
+
if (startDate && postCreatedAt < `${startDate}T00:00:00.000Z`) matchesDate = false;
|
|
214
|
+
if (endDate && postCreatedAt > `${endDate}T23:59:59.999Z`) matchesDate = false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!hasVersion1 && (currentVersion ?? 0) > 1 && matchesDate) {
|
|
218
|
+
allRevisions.push({
|
|
219
|
+
id: -1,
|
|
220
|
+
version: 1,
|
|
221
|
+
revision_type: 'snapshot',
|
|
222
|
+
created_at: postCreatedAt ?? new Date().toISOString(),
|
|
223
|
+
author_id: null,
|
|
224
|
+
author: { full_name: 'System (Initial)', username: 'system' },
|
|
225
|
+
has_changes: true,
|
|
226
|
+
content: null
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
allRevisions.sort((a, b) => b.version - a.version);
|
|
231
|
+
|
|
232
|
+
const totalFiltered = allRevisions.length;
|
|
233
|
+
const totalPages = Math.ceil(totalFiltered / REVISIONS_PER_PAGE);
|
|
234
|
+
|
|
235
|
+
const fromIndex = (page - 1) * REVISIONS_PER_PAGE;
|
|
236
|
+
// Use map to strip heavy content if not needed, but keep flags
|
|
237
|
+
const slicedRevisions = allRevisions
|
|
238
|
+
.slice(fromIndex, fromIndex + REVISIONS_PER_PAGE)
|
|
239
|
+
.map(({ content, ...rest }) => rest);
|
|
240
|
+
|
|
241
|
+
return { success: true as const, revisions: slicedRevisions, currentVersion, count: totalFiltered, totalPages, hasMore: (page * REVISIONS_PER_PAGE) < totalFiltered };
|
|
41
242
|
}
|
|
42
243
|
|
|
43
244
|
export async function restorePageVersion(pageId: number, targetVersion: number) {
|
|
@@ -28,12 +28,31 @@ export async function createPageRevision(
|
|
|
28
28
|
.single();
|
|
29
29
|
if (pageError || !page) return { error: 'Page not found' } as const;
|
|
30
30
|
|
|
31
|
-
const
|
|
32
|
-
const
|
|
31
|
+
const currentVersion = page.version ?? 1;
|
|
32
|
+
const nextVersion = currentVersion + 1;
|
|
33
|
+
|
|
34
|
+
// If we are moving to version 2, it means Version 1 was never saved (it was the initial state).
|
|
35
|
+
// We should save Version 1 now so we have a history base.
|
|
36
|
+
if (nextVersion === 2) {
|
|
37
|
+
await supabase.from('page_revisions').insert({
|
|
38
|
+
page_id: pageId,
|
|
39
|
+
author_id: authorId, // Can be current author or null
|
|
40
|
+
version: 1,
|
|
41
|
+
revision_type: 'snapshot',
|
|
42
|
+
content: previousContent as unknown as Json,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const makeSnapshot = shouldCreateSnapshot(currentVersion) || nextVersion === 2; // ensure early snapshot cadence
|
|
33
47
|
|
|
34
48
|
const revisionType: 'snapshot' | 'diff' = makeSnapshot ? 'snapshot' : 'diff';
|
|
35
49
|
const content: Json = makeSnapshot ? (newContent as unknown as Json) : (compare(previousContent, newContent) as unknown as Json);
|
|
36
50
|
|
|
51
|
+
// If it's a diff and there are no changes, skip creating revision
|
|
52
|
+
if (revisionType === 'diff' && Array.isArray(content) && content.length === 0) {
|
|
53
|
+
return { success: true as const, version: currentVersion }; // Return current version as we didn't bump
|
|
54
|
+
}
|
|
55
|
+
|
|
37
56
|
const { error: insertError } = await supabase.from('page_revisions').insert({
|
|
38
57
|
page_id: pageId,
|
|
39
58
|
author_id: authorId,
|
|
@@ -67,12 +86,28 @@ export async function createPostRevision(
|
|
|
67
86
|
.single();
|
|
68
87
|
if (postError || !post) return { error: 'Post not found' } as const;
|
|
69
88
|
|
|
70
|
-
const
|
|
71
|
-
const
|
|
89
|
+
const currentVersion = post.version ?? 1;
|
|
90
|
+
const nextVersion = currentVersion + 1;
|
|
91
|
+
|
|
92
|
+
if (nextVersion === 2) {
|
|
93
|
+
await supabase.from('post_revisions').insert({
|
|
94
|
+
post_id: postId,
|
|
95
|
+
author_id: authorId,
|
|
96
|
+
version: 1,
|
|
97
|
+
revision_type: 'snapshot',
|
|
98
|
+
content: previousContent as unknown as Json,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const makeSnapshot = shouldCreateSnapshot(currentVersion) || nextVersion === 2;
|
|
72
103
|
|
|
73
104
|
const revisionType: 'snapshot' | 'diff' = makeSnapshot ? 'snapshot' : 'diff';
|
|
74
105
|
const content: Json = makeSnapshot ? (newContent as unknown as Json) : (compare(previousContent, newContent) as unknown as Json);
|
|
75
106
|
|
|
107
|
+
if (revisionType === 'diff' && Array.isArray(content) && content.length === 0) {
|
|
108
|
+
return { success: true as const, version: currentVersion };
|
|
109
|
+
}
|
|
110
|
+
|
|
76
111
|
const { error: insertError } = await supabase.from('post_revisions').insert({
|
|
77
112
|
post_id: postId,
|
|
78
113
|
author_id: authorId,
|
|
@@ -104,27 +139,50 @@ export async function restorePageToVersion(pageId: number, targetVersion: number
|
|
|
104
139
|
.order('version', { ascending: false })
|
|
105
140
|
.limit(1)
|
|
106
141
|
.maybeSingle();
|
|
107
|
-
if (snapshotError || !snapshot) return { error: 'No snapshot found at or before target version.' } as const;
|
|
108
|
-
|
|
109
|
-
let content = snapshot.content as unknown as FullPageContent;
|
|
110
142
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
143
|
+
let content: FullPageContent;
|
|
144
|
+
let baseVersion = 0;
|
|
145
|
+
|
|
146
|
+
if (snapshot) {
|
|
147
|
+
content = snapshot.content as unknown as FullPageContent;
|
|
148
|
+
baseVersion = snapshot.version;
|
|
149
|
+
} else if (targetVersion === 1) {
|
|
150
|
+
// Fallback for missing Version 1: use empty content with current meta
|
|
151
|
+
const { data: pageMeta } = await supabase
|
|
152
|
+
.from('pages')
|
|
153
|
+
.select('title, slug, language_id, status, meta_title, meta_description')
|
|
154
|
+
.eq('id', pageId)
|
|
155
|
+
.single();
|
|
156
|
+
if (!pageMeta) return { error: 'Page not found.' } as const;
|
|
157
|
+
content = {
|
|
158
|
+
meta: pageMeta,
|
|
159
|
+
blocks: [],
|
|
160
|
+
};
|
|
161
|
+
baseVersion = 1;
|
|
162
|
+
} else {
|
|
163
|
+
if (snapshotError) return { error: `Snapshot error: ${snapshotError.message}` } as const;
|
|
164
|
+
return { error: 'No snapshot found at or before target version.' } as const;
|
|
165
|
+
}
|
|
120
166
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
167
|
+
// 2. Fetch diffs up to target and apply (only if we are not already at target)
|
|
168
|
+
if (baseVersion < targetVersion) {
|
|
169
|
+
const { data: diffs, error: diffsError } = await supabase
|
|
170
|
+
.from('page_revisions')
|
|
171
|
+
.select('version, content, revision_type')
|
|
172
|
+
.eq('page_id', pageId)
|
|
173
|
+
.gt('version', baseVersion)
|
|
174
|
+
.lte('version', targetVersion)
|
|
175
|
+
.order('version', { ascending: true });
|
|
176
|
+
if (diffsError) return { error: `Failed to fetch diffs: ${diffsError.message}` } as const;
|
|
177
|
+
|
|
178
|
+
for (const r of diffs || []) {
|
|
179
|
+
if (r.revision_type === 'diff') {
|
|
180
|
+
const ops = r.content as any[];
|
|
181
|
+
const result = applyPatch(content as any, ops, /*validate*/ false, /*mutateDocument*/ true);
|
|
182
|
+
content = result.newDocument as unknown as FullPageContent;
|
|
183
|
+
} else {
|
|
184
|
+
content = r.content as unknown as FullPageContent;
|
|
185
|
+
}
|
|
128
186
|
}
|
|
129
187
|
}
|
|
130
188
|
|
|
@@ -193,17 +251,38 @@ export async function restorePostToVersion(postId: number, targetVersion: number
|
|
|
193
251
|
.order('version', { ascending: false })
|
|
194
252
|
.limit(1)
|
|
195
253
|
.maybeSingle();
|
|
196
|
-
if (snapshotError || !snapshot) return { error: 'No snapshot found at or before target version.' } as const;
|
|
197
254
|
|
|
198
|
-
let content
|
|
255
|
+
let content: FullPostContent;
|
|
256
|
+
let baseVersion = 0;
|
|
257
|
+
|
|
258
|
+
if (snapshot) {
|
|
259
|
+
content = snapshot.content as unknown as FullPostContent;
|
|
260
|
+
baseVersion = snapshot.version;
|
|
261
|
+
} else if (targetVersion === 1) {
|
|
262
|
+
const { data: postMeta } = await supabase
|
|
263
|
+
.from('posts')
|
|
264
|
+
.select('title, slug, language_id, status, meta_title, meta_description, excerpt, published_at, feature_image_id')
|
|
265
|
+
.eq('id', postId)
|
|
266
|
+
.single();
|
|
267
|
+
if (!postMeta) return { error: 'Post not found.' } as const;
|
|
268
|
+
content = {
|
|
269
|
+
meta: postMeta,
|
|
270
|
+
blocks: [],
|
|
271
|
+
};
|
|
272
|
+
baseVersion = 1;
|
|
273
|
+
} else {
|
|
274
|
+
if (snapshotError) return { error: `Snapshot error: ${snapshotError.message}` } as const;
|
|
275
|
+
return { error: 'No snapshot found at or before target version.' } as const;
|
|
276
|
+
}
|
|
199
277
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
278
|
+
if (baseVersion < targetVersion) {
|
|
279
|
+
const { data: diffs, error: diffsError } = await supabase
|
|
280
|
+
.from('post_revisions')
|
|
281
|
+
.select('version, content, revision_type')
|
|
282
|
+
.eq('post_id', postId)
|
|
283
|
+
.gt('version', baseVersion)
|
|
284
|
+
.lte('version', targetVersion)
|
|
285
|
+
.order('version', { ascending: true });
|
|
207
286
|
if (diffsError) return { error: `Failed to fetch diffs: ${diffsError.message}` } as const;
|
|
208
287
|
|
|
209
288
|
for (const r of diffs || []) {
|
|
@@ -214,6 +293,7 @@ export async function restorePostToVersion(postId: number, targetVersion: number
|
|
|
214
293
|
} else {
|
|
215
294
|
content = r.content as unknown as FullPostContent;
|
|
216
295
|
}
|
|
296
|
+
}
|
|
217
297
|
}
|
|
218
298
|
|
|
219
299
|
// Determine next version for post
|
|
@@ -281,17 +361,38 @@ export async function reconstructPageVersionContent(pageId: number, targetVersio
|
|
|
281
361
|
.order('version', { ascending: false })
|
|
282
362
|
.limit(1)
|
|
283
363
|
.maybeSingle();
|
|
284
|
-
if (snapshotError || !snapshot) return { error: 'No snapshot found at or before target version.' } as const;
|
|
285
364
|
|
|
286
|
-
let content
|
|
365
|
+
let content: FullPageContent;
|
|
366
|
+
let baseVersion = 0;
|
|
367
|
+
|
|
368
|
+
if (snapshot) {
|
|
369
|
+
content = snapshot.content as unknown as FullPageContent;
|
|
370
|
+
baseVersion = snapshot.version;
|
|
371
|
+
} else if (targetVersion === 1) {
|
|
372
|
+
const { data: pageMeta } = await supabase
|
|
373
|
+
.from('pages')
|
|
374
|
+
.select('title, slug, language_id, status, meta_title, meta_description')
|
|
375
|
+
.eq('id', pageId)
|
|
376
|
+
.single();
|
|
377
|
+
if (!pageMeta) return { error: 'Page not found.' } as const;
|
|
378
|
+
content = {
|
|
379
|
+
meta: pageMeta,
|
|
380
|
+
blocks: [],
|
|
381
|
+
};
|
|
382
|
+
baseVersion = 1;
|
|
383
|
+
} else {
|
|
384
|
+
if (snapshotError) return { error: `Snapshot error: ${snapshotError.message}` } as const;
|
|
385
|
+
return { error: 'No snapshot found at or before target version.' } as const;
|
|
386
|
+
}
|
|
287
387
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
388
|
+
if (baseVersion < targetVersion) {
|
|
389
|
+
const { data: diffs, error: diffsError } = await supabase
|
|
390
|
+
.from('page_revisions')
|
|
391
|
+
.select('version, content, revision_type')
|
|
392
|
+
.eq('page_id', pageId)
|
|
393
|
+
.gt('version', baseVersion)
|
|
394
|
+
.lte('version', targetVersion)
|
|
395
|
+
.order('version', { ascending: true });
|
|
295
396
|
if (diffsError) return { error: `Failed to fetch diffs: ${diffsError.message}` } as const;
|
|
296
397
|
|
|
297
398
|
for (const r of diffs || []) {
|
|
@@ -303,6 +404,7 @@ export async function reconstructPageVersionContent(pageId: number, targetVersio
|
|
|
303
404
|
content = r.content as unknown as FullPageContent;
|
|
304
405
|
}
|
|
305
406
|
}
|
|
407
|
+
}
|
|
306
408
|
return { success: true as const, content };
|
|
307
409
|
}
|
|
308
410
|
|
|
@@ -318,17 +420,38 @@ export async function reconstructPostVersionContent(postId: number, targetVersio
|
|
|
318
420
|
.order('version', { ascending: false })
|
|
319
421
|
.limit(1)
|
|
320
422
|
.maybeSingle();
|
|
321
|
-
if (snapshotError || !snapshot) return { error: 'No snapshot found at or before target version.' } as const;
|
|
322
423
|
|
|
323
|
-
let content
|
|
424
|
+
let content: FullPostContent;
|
|
425
|
+
let baseVersion = 0;
|
|
426
|
+
|
|
427
|
+
if (snapshot) {
|
|
428
|
+
content = snapshot.content as unknown as FullPostContent;
|
|
429
|
+
baseVersion = snapshot.version;
|
|
430
|
+
} else if (targetVersion === 1) {
|
|
431
|
+
const { data: postMeta } = await supabase
|
|
432
|
+
.from('posts')
|
|
433
|
+
.select('title, slug, language_id, status, meta_title, meta_description, excerpt, published_at, feature_image_id')
|
|
434
|
+
.eq('id', postId)
|
|
435
|
+
.single();
|
|
436
|
+
if (!postMeta) return { error: 'Post not found.' } as const;
|
|
437
|
+
content = {
|
|
438
|
+
meta: postMeta,
|
|
439
|
+
blocks: [],
|
|
440
|
+
};
|
|
441
|
+
baseVersion = 1;
|
|
442
|
+
} else {
|
|
443
|
+
if (snapshotError) return { error: `Snapshot error: ${snapshotError.message}` } as const;
|
|
444
|
+
return { error: 'No snapshot found at or before target version.' } as const;
|
|
445
|
+
}
|
|
324
446
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
447
|
+
if (baseVersion < targetVersion) {
|
|
448
|
+
const { data: diffs, error: diffsError } = await supabase
|
|
449
|
+
.from('post_revisions')
|
|
450
|
+
.select('version, content, revision_type')
|
|
451
|
+
.eq('post_id', postId)
|
|
452
|
+
.gt('version', baseVersion)
|
|
453
|
+
.lte('version', targetVersion)
|
|
454
|
+
.order('version', { ascending: true });
|
|
332
455
|
if (diffsError) return { error: `Failed to fetch diffs: ${diffsError.message}` } as const;
|
|
333
456
|
|
|
334
457
|
for (const r of diffs || []) {
|
|
@@ -340,5 +463,6 @@ export async function reconstructPostVersionContent(postId: number, targetVersio
|
|
|
340
463
|
content = r.content as unknown as FullPostContent;
|
|
341
464
|
}
|
|
342
465
|
}
|
|
466
|
+
}
|
|
343
467
|
return { success: true as const, content };
|
|
344
468
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="next" />
|
|
2
2
|
/// <reference types="next/image-types/global" />
|
|
3
|
-
import "./.next/
|
|
3
|
+
import "./.next/types/routes.d.ts";
|
|
4
4
|
|
|
5
5
|
// NOTE: This file should not be edited
|
|
6
6
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|