create-nextblock 0.2.59 → 0.2.62
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/(auth-pages)/sign-in/page.tsx +4 -1
- package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +4 -1
- package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +47 -0
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +1 -0
- 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/app/layout.tsx +4 -0
- package/templates/nextblock-template/components/SandboxBanner.tsx +17 -0
- package/templates/nextblock-template/components/SandboxCredentialsAlert.tsx +29 -0
- package/templates/nextblock-template/package.json +3 -2
- package/templates/nextblock-template/proxy.ts +6 -6
- package/templates/nextblock-template/tools/deploy-supabase.js +161 -0
package/package.json
CHANGED
|
@@ -9,6 +9,8 @@ import Link from "next/link";
|
|
|
9
9
|
import { useTranslations } from "@nextblock-cms/utils";
|
|
10
10
|
import { useSearchParams } from "next/navigation";
|
|
11
11
|
|
|
12
|
+
import { SandboxCredentialsAlert } from "../../../components/SandboxCredentialsAlert";
|
|
13
|
+
|
|
12
14
|
function getMessage(searchParams: URLSearchParams): Message | undefined {
|
|
13
15
|
if (searchParams.has('error')) {
|
|
14
16
|
const error = searchParams.get('error');
|
|
@@ -31,7 +33,8 @@ export default function Login() {
|
|
|
31
33
|
const formMessage = getMessage(searchParams);
|
|
32
34
|
|
|
33
35
|
return (
|
|
34
|
-
<form className="flex-1 flex flex-col
|
|
36
|
+
<form className="flex-1 flex flex-col w-full max-w-160 mx-auto">
|
|
37
|
+
<SandboxCredentialsAlert />
|
|
35
38
|
<h1 className="text-2xl font-medium">{t('sign_in')}</h1>
|
|
36
39
|
<p className="text-sm text-foreground">
|
|
37
40
|
{t('dont_have_account')}{" "}
|
|
@@ -9,6 +9,8 @@ import Link from "next/link";
|
|
|
9
9
|
import { useTranslations } from "@nextblock-cms/utils";
|
|
10
10
|
import { useSearchParams } from "next/navigation";
|
|
11
11
|
|
|
12
|
+
import { SandboxCredentialsAlert } from "../../../components/SandboxCredentialsAlert";
|
|
13
|
+
|
|
12
14
|
function getMessage(searchParams: URLSearchParams): Message | undefined {
|
|
13
15
|
if (searchParams.has('error')) {
|
|
14
16
|
const error = searchParams.get('error');
|
|
@@ -40,7 +42,8 @@ export default function Signup() {
|
|
|
40
42
|
|
|
41
43
|
return (
|
|
42
44
|
<>
|
|
43
|
-
<form className="flex flex-col
|
|
45
|
+
<form className="flex flex-col w-full max-w-160 mx-auto">
|
|
46
|
+
<SandboxCredentialsAlert />
|
|
44
47
|
<h1 className="text-2xl font-medium">{t('sign_up')}</h1>
|
|
45
48
|
<p className="text-sm text text-foreground">
|
|
46
49
|
{t('already_have_account')}{" "}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { createClient } from '@supabase/supabase-js';
|
|
3
|
+
|
|
4
|
+
export const dynamic = 'force-dynamic';
|
|
5
|
+
|
|
6
|
+
export async function GET(request: NextRequest) {
|
|
7
|
+
// 1. Guard: Only run in Sandbox Mode
|
|
8
|
+
if (process.env.NEXT_PUBLIC_IS_SANDBOX !== 'true') {
|
|
9
|
+
return NextResponse.json({ message: 'Sandbox reset skipped: Not in Sandbox Mode' });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// 2. Guard: Verify Cron Secret
|
|
13
|
+
const authHeader = request.headers.get('authorization');
|
|
14
|
+
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
|
15
|
+
return new NextResponse('Unauthorized', {
|
|
16
|
+
status: 401,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
21
|
+
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
22
|
+
|
|
23
|
+
if (!supabaseUrl || !supabaseServiceKey) {
|
|
24
|
+
return NextResponse.json({ error: 'Missing Supabase environment variables' }, { status: 500 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
|
|
28
|
+
auth: {
|
|
29
|
+
autoRefreshToken: false,
|
|
30
|
+
persistSession: false,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const { error } = await supabase.rpc('reset_sandbox');
|
|
36
|
+
|
|
37
|
+
if (error) {
|
|
38
|
+
console.error('Error resetting sandbox:', error);
|
|
39
|
+
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return NextResponse.json({ success: true, message: 'Sandbox reset successfully' });
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error('Unexpected error resetting sandbox:', err);
|
|
45
|
+
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -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
|
}
|
|
@@ -2,6 +2,8 @@ import '@nextblock-cms/ui/styles/globals.css';
|
|
|
2
2
|
import '@nextblock-cms/editor/styles/editor.css';
|
|
3
3
|
// app/layout.tsx
|
|
4
4
|
import { EnvVarWarning } from "@/components/env-var-warning";
|
|
5
|
+
import { SandboxBanner } from "@/components/SandboxBanner";
|
|
6
|
+
import { Analytics } from "@vercel/analytics/next"
|
|
5
7
|
import { ThemeSwitcher } from '@/components/theme-switcher';
|
|
6
8
|
import type { Metadata } from 'next';
|
|
7
9
|
import Header from "@/components/Header";
|
|
@@ -172,6 +174,7 @@ export default async function RootLayout({
|
|
|
172
174
|
<link rel="dns-prefetch" href="https://db.ppcppwsfnrptznvbxnsz.supabase.co" />
|
|
173
175
|
<link rel="dns-prefetch" href="https://realtime.supabase.com" />
|
|
174
176
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
177
|
+
<Analytics/>
|
|
175
178
|
</head>
|
|
176
179
|
<body className="bg-background text-foreground min-h-screen flex flex-col">
|
|
177
180
|
<Providers
|
|
@@ -183,6 +186,7 @@ export default async function RootLayout({
|
|
|
183
186
|
translations={translations}
|
|
184
187
|
nonce={nonce}
|
|
185
188
|
>
|
|
189
|
+
{process.env.NEXT_PUBLIC_IS_SANDBOX === 'true' && <SandboxBanner />}
|
|
186
190
|
<ToasterProvider />
|
|
187
191
|
<div className="flex-1 w-full flex flex-col items-center">
|
|
188
192
|
<nav className="w-full flex justify-center border-b border-b-foreground/10 h-16">
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { TriangleAlert } from 'lucide-react';
|
|
4
|
+
import { useTranslations } from '@nextblock-cms/utils';
|
|
5
|
+
|
|
6
|
+
export function SandboxBanner() {
|
|
7
|
+
const { t } = useTranslations();
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="w-full z-[100] bg-amber-500 text-amber-950 px-4 py-2 text-center text-sm font-medium shadow-md flex items-center justify-center gap-2">
|
|
11
|
+
<TriangleAlert className="w-4 h-4" />
|
|
12
|
+
<span>
|
|
13
|
+
{t('sandbox_mode_banner')}
|
|
14
|
+
</span>
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Info } from 'lucide-react';
|
|
4
|
+
import { useTranslations } from '@nextblock-cms/utils';
|
|
5
|
+
|
|
6
|
+
export function SandboxCredentialsAlert() {
|
|
7
|
+
const { t } = useTranslations();
|
|
8
|
+
|
|
9
|
+
// Only show in sandbox mode
|
|
10
|
+
if (process.env.NEXT_PUBLIC_IS_SANDBOX !== 'true') {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mt-6 flex items-start gap-3 text-sm">
|
|
16
|
+
<Info className="w-5 h-5 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
|
|
17
|
+
<div className="text-blue-900 dark:text-blue-100">
|
|
18
|
+
<p className="font-semibold mb-1">{t('demo_access_title')}</p>
|
|
19
|
+
<p className="mb-2">
|
|
20
|
+
{t('demo_access_desc')}
|
|
21
|
+
</p>
|
|
22
|
+
<ul className="space-y-1 font-mono text-xs bg-white/50 dark:bg-black/20 p-2 rounded border border-blue-100 dark:border-blue-900/50">
|
|
23
|
+
<li>{t('demo_user_label')} <span className="font-bold select-all">demo@nextblock.ca</span></li>
|
|
24
|
+
<li>{t('demo_password_label')} <span className="font-bold select-all">password</span></li>
|
|
25
|
+
</ul>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextblock-cms/template",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.40",
|
|
4
4
|
"private": true,
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "next dev",
|
|
7
7
|
"build": "next build",
|
|
8
8
|
"start": "next start",
|
|
9
|
-
"lint": "next lint"
|
|
9
|
+
"lint": "next lint",
|
|
10
|
+
"deploy:supabase": "node tools/deploy-supabase.js"
|
|
10
11
|
},
|
|
11
12
|
"dependencies": {
|
|
12
13
|
"@aws-sdk/client-s3": "^3.920.0",
|
|
@@ -214,13 +214,13 @@ export default async function proxy(request: NextRequest) {
|
|
|
214
214
|
|
|
215
215
|
const csp = [
|
|
216
216
|
"default-src 'self'",
|
|
217
|
-
`script-src 'self' blob: data: 'nonce-${nonceValue}'`,
|
|
218
|
-
"style-src 'self' 'unsafe-inline'",
|
|
219
|
-
`img-src 'self' data: blob:${r2Hostnames}`,
|
|
220
|
-
"font-src 'self'",
|
|
217
|
+
`script-src 'self' blob: data: 'nonce-${nonceValue}' https://vercel.live https://vercel.com`,
|
|
218
|
+
"style-src 'self' 'unsafe-inline' https://vercel.live https://vercel.com",
|
|
219
|
+
`img-src 'self' data: blob:${r2Hostnames} https://vercel.live https://vercel.com`,
|
|
220
|
+
"font-src 'self' https://vercel.live https://assets.vercel.com",
|
|
221
221
|
"object-src 'none'",
|
|
222
|
-
`connect-src 'self' https://${supabaseHostname} wss://${supabaseHostname}${r2Hostnames}`,
|
|
223
|
-
"frame-src 'self' blob: data: https://www.youtube.com",
|
|
222
|
+
`connect-src 'self' https://${supabaseHostname} wss://${supabaseHostname}${r2Hostnames} https://vercel.live https://vercel.com`,
|
|
223
|
+
"frame-src 'self' blob: data: https://www.youtube.com https://vercel.live https://vercel.com",
|
|
224
224
|
"form-action 'self'",
|
|
225
225
|
"base-uri 'self'",
|
|
226
226
|
].join('; ');
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
// Colors for console output
|
|
6
|
+
const colors = {
|
|
7
|
+
reset: '\x1b[0m',
|
|
8
|
+
green: '\x1b[32m',
|
|
9
|
+
yellow: '\x1b[33m',
|
|
10
|
+
red: '\x1b[31m',
|
|
11
|
+
blue: '\x1b[34m',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function getDbPassword() {
|
|
15
|
+
if (process.env.SUPABASE_DB_PASSWORD) {
|
|
16
|
+
return process.env.SUPABASE_DB_PASSWORD;
|
|
17
|
+
}
|
|
18
|
+
if (process.env.POSTGRES_URL) {
|
|
19
|
+
try {
|
|
20
|
+
const url = new URL(process.env.POSTGRES_URL);
|
|
21
|
+
return url.password;
|
|
22
|
+
} catch (e) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function checkEnv() {
|
|
30
|
+
const missing = [];
|
|
31
|
+
|
|
32
|
+
if (!process.env.SUPABASE_ACCESS_TOKEN) {
|
|
33
|
+
missing.push('SUPABASE_ACCESS_TOKEN');
|
|
34
|
+
}
|
|
35
|
+
if (!process.env.SUPABASE_PROJECT_ID) {
|
|
36
|
+
missing.push('SUPABASE_PROJECT_ID');
|
|
37
|
+
}
|
|
38
|
+
if (!process.env.NEXT_PUBLIC_URL) {
|
|
39
|
+
missing.push('NEXT_PUBLIC_URL');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const dbPassword = getDbPassword();
|
|
43
|
+
if (!dbPassword) {
|
|
44
|
+
missing.push('SUPABASE_DB_PASSWORD (or POSTGRES_URL)');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (missing.length > 0) {
|
|
48
|
+
console.log(
|
|
49
|
+
`${colors.yellow}⚠️ Skipping Supabase deployment: Missing environment variables: ${missing.join(', ')}${colors.reset}`,
|
|
50
|
+
);
|
|
51
|
+
console.log(
|
|
52
|
+
`${colors.yellow} This is expected for Pull Requests or forks without secrets configured.${colors.reset}`,
|
|
53
|
+
);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function runCommand(command) {
|
|
60
|
+
try {
|
|
61
|
+
console.log(`${colors.blue}Running: ${command}${colors.reset}`);
|
|
62
|
+
execSync(command, { stdio: 'inherit' });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error(`${colors.red}❌ Command failed: ${command}${colors.reset}`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function deploy() {
|
|
70
|
+
console.log(
|
|
71
|
+
`${colors.green}🚀 Starting Supabase Deployment...${colors.reset}`,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (!checkEnv()) {
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const dbPassword = getDbPassword();
|
|
79
|
+
if (!dbPassword) {
|
|
80
|
+
console.error(
|
|
81
|
+
`${colors.red}❌ Could not determine database password.${colors.reset}`,
|
|
82
|
+
);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Detect Supabase location
|
|
87
|
+
// 1. Monorepo Dev (apps/nextblock -> libs/db/src)
|
|
88
|
+
// 2. Standalone (root -> supabase)
|
|
89
|
+
let workDirFlag = '';
|
|
90
|
+
|
|
91
|
+
// Check if we are in monorepo structure relative to this script
|
|
92
|
+
// Script is in apps/nextblock/tools/deploy-supabase.js
|
|
93
|
+
// So monorepo root is ../../../
|
|
94
|
+
// libs/db/src is ../../../libs/db/src
|
|
95
|
+
|
|
96
|
+
// More robust check: check if libs/db/src/supabase/config.toml exists
|
|
97
|
+
// We assume the command is run from the project root (process.cwd())
|
|
98
|
+
|
|
99
|
+
// If running from apps/nextblock root (monorepo dev)
|
|
100
|
+
const monorepoDbPath = path.join(
|
|
101
|
+
process.cwd(),
|
|
102
|
+
'../../libs/db/src/supabase/config.toml',
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// If running from workspace root (standalone)
|
|
106
|
+
const standaloneDbPath = path.join(process.cwd(), 'supabase/config.toml');
|
|
107
|
+
|
|
108
|
+
if (fs.existsSync(monorepoDbPath)) {
|
|
109
|
+
console.log(
|
|
110
|
+
`${colors.blue}ℹ️ Detected Monorepo environment (libs/db/src)${colors.reset}`,
|
|
111
|
+
);
|
|
112
|
+
workDirFlag = '--workdir ../../libs/db/src';
|
|
113
|
+
} else if (fs.existsSync(standaloneDbPath)) {
|
|
114
|
+
console.log(
|
|
115
|
+
`${colors.blue}ℹ️ Detected Standalone environment (./supabase)${colors.reset}`,
|
|
116
|
+
);
|
|
117
|
+
workDirFlag = '';
|
|
118
|
+
} else {
|
|
119
|
+
// Fallback or maybe we are running from root of monorepo?
|
|
120
|
+
const rootDbPath = path.join(
|
|
121
|
+
process.cwd(),
|
|
122
|
+
'libs/db/src/supabase/config.toml',
|
|
123
|
+
);
|
|
124
|
+
if (fs.existsSync(rootDbPath)) {
|
|
125
|
+
workDirFlag = '--workdir libs/db/src';
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log(
|
|
130
|
+
`${colors.green}🔗 Linking to Supabase project...${colors.reset}`,
|
|
131
|
+
);
|
|
132
|
+
runCommand(
|
|
133
|
+
`npx supabase link --project-ref ${process.env.SUPABASE_PROJECT_ID} --password ${dbPassword} ${workDirFlag}`,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
console.log(
|
|
137
|
+
`${colors.green}📦 Pushing database migrations...${colors.reset}`,
|
|
138
|
+
);
|
|
139
|
+
runCommand(`npx supabase db push --include-all ${workDirFlag}`, {
|
|
140
|
+
stdio: 'inherit',
|
|
141
|
+
}); // db push input 'y' handled?
|
|
142
|
+
// npx supabase db push usually asks for confirmation if destructive. --include-all might implies force? using input 'y' might be safer or --force if avail.
|
|
143
|
+
// Actually, CI usually needs --no-interactive or equivalent if prompts exist.
|
|
144
|
+
// But our previous script used just db push without input 'y' and relied on process.stdin or just worked.
|
|
145
|
+
// Wait, in create-nextblock.js we piped 'y\n'.
|
|
146
|
+
// In deploy-supabase.js we are using inherit stdio, so it might prompt if strictly necessary, but usually db push to remote is fine unless destructive.
|
|
147
|
+
// However, I previously wrote `npx supabase db push ...` and it seemed fine.
|
|
148
|
+
|
|
149
|
+
// Re-reading previous script: runCommand used `execSync(command, { stdio: 'inherit' });`
|
|
150
|
+
|
|
151
|
+
// Let's stick to the previous working command.
|
|
152
|
+
|
|
153
|
+
console.log(
|
|
154
|
+
`${colors.green}⚙️ Pushing Supabase config (Site URL: ${process.env.NEXT_PUBLIC_URL})...${colors.reset}`,
|
|
155
|
+
);
|
|
156
|
+
runCommand(`npx supabase config push ${workDirFlag}`);
|
|
157
|
+
|
|
158
|
+
console.log(`${colors.green}✅ Supabase deployment complete!${colors.reset}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
deploy();
|