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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextblock",
3
- "version": "0.2.59",
3
+ "version": "0.2.62",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -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 min-w-64 mx-auto">
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 min-w-64 max-w-64 mx-auto">
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
+ }
@@ -114,6 +114,7 @@ export function BlockEditorModal({
114
114
 
115
115
  {/* Editor Area with Contextual Background */}
116
116
  <div
117
+ onClick={(e) => e.stopPropagation()}
117
118
  className={cn(
118
119
  "flex-1 overflow-y-auto p-6",
119
120
  // Conditional Background Logic:
@@ -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[] | null>(null);
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
- useEffect(() => {
51
- if (!open) return;
58
+
59
+ const loadRevisions = async (pageToLoad: number, start?: string, end?: string) => {
52
60
  setLoading(true);
53
61
  setError(null);
54
- (async () => {
55
- try {
56
- if (parentType === 'page') {
57
- const res = await listPageRevisions(parentId);
58
- if ('error' in res) {
59
- setError(res.error ?? 'Unknown error');
60
- setRevisions(null);
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 className="max-w-2xl h-[95vh] overflow-y-auto">
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
- <DialogTitle>Revision History</DialogTitle>
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
- <div className="space-y-3">
143
- {loading && (
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
- {(!loading && revisions && revisions.length === 0) && (
160
- <div className="text-sm text-muted-foreground">No revisions yet.</div>
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 who = rev.author?.full_name || rev.author?.username || rev.author_id || 'Unknown';
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
- <div key={rev.id} className="flex items-center justify-between gap-4 p-3">
170
- <div className="min-w-0">
171
- <div className="font-medium flex items-center gap-2">
172
- <span>Version {rev.version}</span>
173
- <span className="text-xs text-muted-foreground">({rev.revision_type})</span>
174
- {currentVersion != null && rev.version === currentVersion && (
175
- <span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-700 border border-blue-200">Current</span>
176
- )}
177
- </div>
178
- <div className="text-xs text-muted-foreground truncate">{when} {who}</div>
179
- </div>
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
- <Button variant="secondary" size="sm" onClick={() => handleCompare(rev.version)} disabled={compareLoading && activeCompareVersion === rev.version}>
182
- {compareLoading && activeCompareVersion === rev.version ? <><Spinner className="mr-2 h-3 w-3" /> Loading</> : 'Compare'}
183
- </Button>
184
- <Button size="sm" onClick={() => handleRestore(rev.version)} disabled={isPending}>
185
- {isPending ? <><Spinner className="mr-2 h-3 w-3" /> Restoring</> : 'Restore'}
186
- </Button>
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
- {(activeCompareVersion !== null) && (
194
- <div className="mt-4">
195
- <div className="mb-2 flex items-center justify-between">
196
- <div className="font-semibold">Comparing Version {activeCompareVersion} to Current</div>
197
- <Button variant="outline" size="sm" onClick={() => { setActiveCompareVersion(null); setLeftText(null); setRightText(null); }}>Close Compare</Button>
198
- </div>
199
- {compareLoading && <div className="text-sm text-muted-foreground">Preparing diff…</div>}
200
- {!compareLoading && leftText && rightText && (
201
- <JsonDiffView
202
- oldValue={leftText}
203
- newValue={rightText}
204
- leftTitle="Current (Now)"
205
- rightTitle={`Version ${activeCompareVersion}`}
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
- export async function listPageRevisions(pageId: number) {
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
- const { data, error } = await supabase
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
- const { data: pageRow } = await supabase.from('pages').select('version').eq('id', pageId).single();
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
- return { success: true as const, revisions: data as RevisionListItem[], currentVersion };
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
- const { data, error } = await supabase
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
- const { data: postRow } = await supabase.from('posts').select('version').eq('id', postId).single();
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
- return { success: true as const, revisions: data as RevisionListItem[], currentVersion };
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 nextVersion = (page.version ?? 1) + 1;
32
- const makeSnapshot = shouldCreateSnapshot(page.version ?? 1) || nextVersion === 2; // ensure early snapshot cadence
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 nextVersion = (post.version ?? 1) + 1;
71
- const makeSnapshot = shouldCreateSnapshot(post.version ?? 1) || nextVersion === 2;
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
- // 2. Fetch diffs up to target and apply
112
- const { data: diffs, error: diffsError } = await supabase
113
- .from('page_revisions')
114
- .select('version, content, revision_type')
115
- .eq('page_id', pageId)
116
- .gt('version', snapshot.version)
117
- .lte('version', targetVersion)
118
- .order('version', { ascending: true });
119
- if (diffsError) return { error: `Failed to fetch diffs: ${diffsError.message}` } as const;
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
- for (const r of diffs || []) {
122
- if (r.revision_type === 'diff') {
123
- const ops = r.content as any[];
124
- const result = applyPatch(content as any, ops, /*validate*/ false, /*mutateDocument*/ true);
125
- content = result.newDocument as unknown as FullPageContent;
126
- } else {
127
- content = r.content as unknown as FullPageContent;
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 = snapshot.content as unknown as FullPostContent;
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
- const { data: diffs, error: diffsError } = await supabase
201
- .from('post_revisions')
202
- .select('version, content, revision_type')
203
- .eq('post_id', postId)
204
- .gt('version', snapshot.version)
205
- .lte('version', targetVersion)
206
- .order('version', { ascending: true });
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 = snapshot.content as unknown as FullPageContent;
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
- const { data: diffs, error: diffsError } = await supabase
289
- .from('page_revisions')
290
- .select('version, content, revision_type')
291
- .eq('page_id', pageId)
292
- .gt('version', snapshot.version)
293
- .lte('version', targetVersion)
294
- .order('version', { ascending: true });
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 = snapshot.content as unknown as FullPostContent;
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
- const { data: diffs, error: diffsError } = await supabase
326
- .from('post_revisions')
327
- .select('version, content, revision_type')
328
- .eq('post_id', postId)
329
- .gt('version', snapshot.version)
330
- .lte('version', targetVersion)
331
- .order('version', { ascending: true });
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.37",
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();