create-nextblock 0.14.2 → 0.14.5

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.
Files changed (68) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/[slug]/page.tsx +7 -2
  3. package/templates/nextblock-template/app/[slug]/page.utils.ts +8 -3
  4. package/templates/nextblock-template/app/actions/postActions.ts +3 -0
  5. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -0
  6. package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +83 -3
  7. package/templates/nextblock-template/app/actions/visualEditingActions.ts +34 -14
  8. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +45 -0
  9. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +736 -117
  10. package/templates/nextblock-template/app/api/view/route.ts +114 -0
  11. package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -2
  12. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +2 -2
  13. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +17 -22
  14. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +13 -19
  15. package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +45 -34
  16. package/templates/nextblock-template/app/cms/components/DraftStatusActions.tsx +10 -0
  17. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -0
  18. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +528 -0
  19. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +33 -17
  20. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +19 -7
  21. package/templates/nextblock-template/app/cms/pages/actions.ts +17 -10
  22. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +7 -29
  23. package/templates/nextblock-template/app/cms/pages/page.tsx +6 -19
  24. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +42 -18
  25. package/templates/nextblock-template/app/cms/posts/actions.ts +16 -27
  26. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +3 -60
  27. package/templates/nextblock-template/app/cms/posts/page.tsx +6 -13
  28. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +63 -9
  29. package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +66 -32
  30. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -285
  31. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -0
  32. package/templates/nextblock-template/app/cms/revisions/service.ts +549 -471
  33. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -132
  34. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -0
  35. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -0
  36. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -24
  37. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -0
  38. package/templates/nextblock-template/app/layout.tsx +49 -0
  39. package/templates/nextblock-template/app/lib/sitemap-utils.ts +6 -4
  40. package/templates/nextblock-template/app/lib/ucp/server.ts +4 -1
  41. package/templates/nextblock-template/app/page.tsx +6 -3
  42. package/templates/nextblock-template/app/product/[slug]/page.tsx +27 -3
  43. package/templates/nextblock-template/app/providers.tsx +16 -4
  44. package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +7 -10
  45. package/templates/nextblock-template/components/theme-icon.tsx +78 -0
  46. package/templates/nextblock-template/components/theme-switcher.tsx +85 -90
  47. package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +4 -1
  48. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -0
  49. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -0
  50. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +7 -2
  51. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +114 -0
  52. package/templates/nextblock-template/lib/blocks/blockColors.ts +132 -0
  53. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +17 -1
  54. package/templates/nextblock-template/lib/cms-transfer/server.ts +13 -0
  55. package/templates/nextblock-template/lib/full-backup/server.ts +1 -0
  56. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -0
  57. package/templates/nextblock-template/lib/search/server.ts +3 -0
  58. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +102 -87
  59. package/templates/nextblock-template/lib/themes/buildThemeCss.test.ts +163 -0
  60. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -0
  61. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -0
  62. package/templates/nextblock-template/lib/themes/tokens.ts +143 -0
  63. package/templates/nextblock-template/lib/visual-editing/mutations.ts +4 -1
  64. package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +46 -1
  65. package/templates/nextblock-template/next-env.d.ts +2 -2
  66. package/templates/nextblock-template/package.json +1 -1
  67. package/templates/nextblock-template/scripts/verify-site-themes.ts +63 -0
  68. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -1,285 +1,332 @@
1
- // apps/nextblock/app/cms/revisions/actions.ts
2
- "use server";
3
-
4
- import { createClient } from "@nextblock-cms/db/server";
5
- import { restorePageToVersion, restorePostToVersion, reconstructPageVersionContent, reconstructPostVersionContent } from './service';
6
- import { getFullPageContent, getFullPostContent } from './utils';
7
- import { compare } from 'fast-json-patch';
8
-
9
- type RevisionListItem = {
10
- id: number;
11
- version: number;
12
- revision_type: 'snapshot' | 'diff';
13
- created_at: string;
14
- author_id: string | null;
15
- author?: { full_name?: string | null } | null;
16
- };
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) {
21
- const supabase = createClient();
22
-
23
- let query = supabase
24
- .from('page_revisions')
25
- .select('id, page_id, author_id, version, revision_type, created_at, content, author:profiles(full_name)', { count: 'exact' })
26
- .eq('page_id', pageId)
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
-
39
- if (error) return { error: error.message } as const;
40
-
41
- const { data: pageRow } = await supabase.from('pages').select('version, created_at').eq('id', pageId).single();
42
- const currentVersion = pageRow?.version ?? null;
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)' },
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
- };
133
- }
134
-
135
- export async function listPostRevisions(postId: number, page = 1, startDate?: string, endDate?: string) {
136
- const supabase = createClient();
137
-
138
-
139
-
140
- let query = supabase
141
- .from('post_revisions')
142
- .select('id, post_id, author_id, version, revision_type, created_at, content, author:profiles(full_name)', { count: 'exact' })
143
- .eq('post_id', postId)
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
-
156
- if (error) return { error: error.message } as const;
157
-
158
- const { data: postRow } = await supabase.from('posts').select('version, created_at').eq('id', postId).single();
159
- const currentVersion = postRow?.version ?? null;
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)' },
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 };
242
- }
243
-
244
- export async function restorePageVersion(pageId: number, targetVersion: number) {
245
- const supabase = createClient();
246
- const { data: { user } } = await supabase.auth.getUser();
247
- if (!user) return { error: 'User not authenticated.' } as const;
248
- // Role checks are enforced by RLS; we can still short-circuit if needed
249
- return await restorePageToVersion(pageId, targetVersion, user.id);
250
- }
251
-
252
- export async function restorePostVersion(postId: number, targetVersion: number) {
253
- const supabase = createClient();
254
- const { data: { user } } = await supabase.auth.getUser();
255
- if (!user) return { error: 'User not authenticated.' } as const;
256
- return await restorePostToVersion(postId, targetVersion, user.id);
257
- }
258
-
259
- import type { FullPageContent, FullPostContent } from './utils';
260
-
261
- type CompareResponse<T> = { success: true; current: T; target: T } | { error: string };
262
-
263
- export async function comparePageVersion(pageId: number, targetVersion: number): Promise<CompareResponse<FullPageContent>> {
264
- const supabase = createClient();
265
- const { data: { user } } = await supabase.auth.getUser();
266
- if (!user) return { error: 'User not authenticated.' } as const;
267
-
268
- const current = await getFullPageContent(pageId);
269
- if (!current) return { error: 'Failed to fetch current content.' } as const;
270
- const reconstructed = await reconstructPageVersionContent(pageId, targetVersion);
271
- if ('error' in reconstructed) return { error: reconstructed.error ?? 'Unknown error occurred while reconstructing page version' } as const;
272
- return { success: true as const, current, target: reconstructed.content };
273
- }
274
-
275
- export async function comparePostVersion(postId: number, targetVersion: number): Promise<CompareResponse<FullPostContent>> {
276
- const supabase = createClient();
277
- const { data: { user } } = await supabase.auth.getUser();
278
- if (!user) return { error: 'User not authenticated.' } as const;
279
-
280
- const current = await getFullPostContent(postId);
281
- if (!current) return { error: 'Failed to fetch current content.' } as const;
282
- const reconstructed = await reconstructPostVersionContent(postId, targetVersion);
283
- if ('error' in reconstructed) return { error: reconstructed.error ?? 'Unknown error occurred while reconstructing post version' } as const;
284
- return { success: true as const, current, target: reconstructed.content };
285
- }
1
+ // apps/nextblock/app/cms/revisions/actions.ts
2
+ "use server";
3
+
4
+ import { revalidatePath } from "next/cache";
5
+ import { createClient } from "@nextblock-cms/db/server";
6
+ import {
7
+ restorePageToVersion,
8
+ restorePostToVersion,
9
+ restoreProductToVersion,
10
+ reconstructPageVersionContent,
11
+ reconstructPostVersionContent,
12
+ reconstructProductVersionContent,
13
+ } from './service';
14
+ import {
15
+ getFullPageContent,
16
+ getFullPostContent,
17
+ getFullProductContent,
18
+ RESTORE_EXCLUDED_META_COLUMNS,
19
+ } from './utils';
20
+ import type { FullPageContent, FullPostContent, FullProductContent } from './utils';
21
+ import { compare } from 'fast-json-patch';
22
+
23
+ type RevisionListItem = {
24
+ id: number;
25
+ version: number;
26
+ revision_type: 'snapshot' | 'diff';
27
+ created_at: string;
28
+ author_id: string | null;
29
+ author?: { full_name?: string | null } | null;
30
+ };
31
+
32
+ const REVISIONS_PER_PAGE = 10;
33
+
34
+ /**
35
+ * Paths whose ops never represent an editorial change.
36
+ *
37
+ * These are anchored to the exact meta path rather than matched with `endsWith`, because a
38
+ * suffix match at any depth also swallows real content — a block whose payload happens to
39
+ * carry an `id` or a `published_at` field would have its edits filtered out and the row
40
+ * would render as "no changes".
41
+ *
42
+ * `status`/`published_at` are here because visibility is owned by the top bar and never
43
+ * replayed on restore (see RESTORE_EXCLUDED_META_COLUMNS); attributing a publish toggle to
44
+ * whoever saved next would be wrong.
45
+ */
46
+ const IGNORED_DIFF_PATHS = new Set<string>([
47
+ '/meta/updated_at',
48
+ '/meta/created_at',
49
+ '/meta/version',
50
+ '/meta/id',
51
+ '/meta/author_id',
52
+ ...RESTORE_EXCLUDED_META_COLUMNS.map(column => `/meta/${column}`),
53
+ ]);
54
+
55
+ function hasMeaningfulOps(ops: unknown): boolean {
56
+ if (!Array.isArray(ops) || ops.length === 0) return false;
57
+ return ops.some((op: any) => !op?.path || !IGNORED_DIFF_PATHS.has(op.path));
58
+ }
59
+
60
+ function parseMaybeJson(value: unknown): unknown {
61
+ if (typeof value !== 'string') return value;
62
+ try {
63
+ return JSON.parse(value);
64
+ } catch {
65
+ return value;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Decide whether a stored revision differs from the live content in a way worth showing.
71
+ * Unknown/unparseable payloads default to "yes" hiding a row the user might need is worse
72
+ * than showing one that turns out to be identical.
73
+ */
74
+ function computeHasChanges(
75
+ revision: { revision_type: string; content: unknown; version: number },
76
+ currentContent: unknown,
77
+ currentVersion: number | null
78
+ ): boolean {
79
+ if (revision.revision_type === 'diff') {
80
+ return hasMeaningfulOps(parseMaybeJson(revision.content));
81
+ }
82
+
83
+ if (!currentContent || revision.version === currentVersion) return true;
84
+
85
+ const snapshotContent = parseMaybeJson(revision.content);
86
+ if (!snapshotContent || typeof snapshotContent !== 'object') return true;
87
+
88
+ return hasMeaningfulOps(compare(currentContent as object, snapshotContent as object));
89
+ }
90
+
91
+ function paginate(
92
+ revisions: (RevisionListItem & { has_changes: boolean; content?: unknown })[],
93
+ page: number
94
+ ) {
95
+ revisions.sort((a, b) => b.version - a.version);
96
+ const count = revisions.length;
97
+ const totalPages = Math.ceil(count / REVISIONS_PER_PAGE);
98
+ const fromIndex = (page - 1) * REVISIONS_PER_PAGE;
99
+ const slice = revisions
100
+ .slice(fromIndex, fromIndex + REVISIONS_PER_PAGE)
101
+ .map(({ content, ...rest }) => rest);
102
+
103
+ return {
104
+ success: true as const,
105
+ revisions: slice,
106
+ count,
107
+ totalPages,
108
+ hasMore: page * REVISIONS_PER_PAGE < count,
109
+ };
110
+ }
111
+
112
+ function applyDateFilters(query: any, startDate?: string, endDate?: string) {
113
+ if (startDate) query = query.gte('created_at', `${startDate}T00:00:00.000Z`);
114
+ if (endDate) query = query.lte('created_at', `${endDate}T23:59:59.999Z`);
115
+ return query;
116
+ }
117
+
118
+ export async function listPageRevisions(pageId: number, page = 1, startDate?: string, endDate?: string) {
119
+ const supabase = createClient();
120
+
121
+ const query = applyDateFilters(
122
+ supabase
123
+ .from('page_revisions')
124
+ .select('id, page_id, author_id, version, revision_type, created_at, content, author:profiles(full_name)')
125
+ .eq('page_id', pageId)
126
+ .order('version', { ascending: false })
127
+ .limit(1000),
128
+ startDate,
129
+ endDate
130
+ );
131
+
132
+ const { data, error } = await query;
133
+ if (error) return { error: error.message } as const;
134
+
135
+ const { data: pageRow } = await supabase.from('pages').select('version').eq('id', pageId).single();
136
+ const currentVersion = pageRow?.version ?? null;
137
+ const currentContent = await getFullPageContent(pageId);
138
+
139
+ const revisions = (data || []).map((r: any) => ({
140
+ ...r,
141
+ has_changes: computeHasChanges(r, currentContent, currentVersion),
142
+ }));
143
+
144
+ return { ...paginate(revisions, page), currentVersion };
145
+ }
146
+
147
+ export async function listPostRevisions(postId: number, page = 1, startDate?: string, endDate?: string) {
148
+ const supabase = createClient();
149
+
150
+ const query = applyDateFilters(
151
+ supabase
152
+ .from('post_revisions')
153
+ .select('id, post_id, author_id, version, revision_type, created_at, content, author:profiles(full_name)')
154
+ .eq('post_id', postId)
155
+ .order('version', { ascending: false })
156
+ .limit(1000),
157
+ startDate,
158
+ endDate
159
+ );
160
+
161
+ const { data, error } = await query;
162
+ if (error) return { error: error.message } as const;
163
+
164
+ const { data: postRow } = await supabase.from('posts').select('version').eq('id', postId).single();
165
+ const currentVersion = postRow?.version ?? null;
166
+ const currentContent = await getFullPostContent(postId);
167
+
168
+ const revisions = (data || []).map((r: any) => ({
169
+ ...r,
170
+ has_changes: computeHasChanges(r, currentContent, currentVersion),
171
+ }));
172
+
173
+ return { ...paginate(revisions, page), currentVersion };
174
+ }
175
+
176
+ export async function listProductRevisions(productId: string, page = 1, startDate?: string, endDate?: string) {
177
+ const supabase = createClient();
178
+
179
+ const query = applyDateFilters(
180
+ (supabase as any)
181
+ .from('product_revisions')
182
+ .select('id, product_id, author_id, version, revision_type, created_at, content, author:profiles(full_name)')
183
+ .eq('product_id', productId)
184
+ .order('version', { ascending: false })
185
+ .limit(1000),
186
+ startDate,
187
+ endDate
188
+ );
189
+
190
+ const { data, error } = await query;
191
+ if (error) return { error: error.message } as const;
192
+
193
+ const { data: productRow } = await (supabase as any)
194
+ .from('products')
195
+ .select('version')
196
+ .eq('id', productId)
197
+ .single();
198
+ const currentVersion = productRow?.version ?? null;
199
+ const currentContent = await getFullProductContent(productId);
200
+
201
+ const revisions = (data || []).map((r: any) => ({
202
+ ...r,
203
+ has_changes: computeHasChanges(r, currentContent, currentVersion),
204
+ }));
205
+
206
+ return { ...paginate(revisions, page), currentVersion };
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Restore
211
+ // ---------------------------------------------------------------------------
212
+
213
+ async function requireUserId() {
214
+ const supabase = createClient();
215
+ const { data: { user } } = await supabase.auth.getUser();
216
+ return user?.id ?? null;
217
+ }
218
+
219
+ export async function restorePageVersion(pageId: number, targetVersion: number) {
220
+ const userId = await requireUserId();
221
+ if (!userId) return { error: 'User not authenticated.' } as const;
222
+
223
+ const supabase = createClient();
224
+ const { data: before } = await supabase.from('pages').select('slug').eq('id', pageId).single();
225
+
226
+ const result = await restorePageToVersion(pageId, targetVersion, userId);
227
+ if ('error' in result) return result;
228
+
229
+ const { data: after } = await supabase.from('pages').select('slug').eq('id', pageId).single();
230
+
231
+ revalidatePath('/cms/pages');
232
+ revalidatePath(`/cms/pages/${pageId}/edit`);
233
+ revalidatePath('/', 'layout');
234
+ if (before?.slug) revalidatePath(`/${before.slug}`);
235
+ if (after?.slug && after.slug !== before?.slug) revalidatePath(`/${after.slug}`);
236
+
237
+ return result;
238
+ }
239
+
240
+ export async function restorePostVersion(postId: number, targetVersion: number) {
241
+ const userId = await requireUserId();
242
+ if (!userId) return { error: 'User not authenticated.' } as const;
243
+
244
+ const supabase = createClient();
245
+ const { data: before } = await supabase.from('posts').select('slug').eq('id', postId).single();
246
+
247
+ const result = await restorePostToVersion(postId, targetVersion, userId);
248
+ if ('error' in result) return result;
249
+
250
+ const { data: after } = await supabase.from('posts').select('slug').eq('id', postId).single();
251
+
252
+ revalidatePath('/cms/posts');
253
+ revalidatePath(`/cms/posts/${postId}/edit`);
254
+ // Public post routes are /article/<slug> (singular) see getPublicPath in
255
+ // lib/visual-editing/draft-content.ts. The listing lives at the seeded /articles page.
256
+ revalidatePath('/articles');
257
+ if (before?.slug) revalidatePath(`/article/${before.slug}`);
258
+ if (after?.slug && after.slug !== before?.slug) revalidatePath(`/article/${after.slug}`);
259
+
260
+ return result;
261
+ }
262
+
263
+ export async function restoreProductVersion(productId: string, targetVersion: number) {
264
+ const userId = await requireUserId();
265
+ if (!userId) return { error: 'User not authenticated.' } as const;
266
+
267
+ // products_update_policy is is_admin()-only. Without this check a WRITER would get an
268
+ // UPDATE matching zero rows, which PostgREST reports as success — a restore that
269
+ // silently did nothing.
270
+ const supabase = createClient();
271
+ const { data: profile } = await supabase.from('profiles').select('role').eq('id', userId).single();
272
+ if (profile?.role !== 'ADMIN') {
273
+ return { error: 'Only an administrator can restore a product version.' } as const;
274
+ }
275
+
276
+ const { data: before } = await (supabase as any)
277
+ .from('products').select('slug').eq('id', productId).single();
278
+
279
+ const result = await restoreProductToVersion(productId, targetVersion, userId);
280
+ if ('error' in result) return result;
281
+
282
+ const { data: after } = await (supabase as any)
283
+ .from('products').select('slug').eq('id', productId).single();
284
+
285
+ revalidatePath('/cms/products');
286
+ revalidatePath(`/cms/products/${productId}/edit`);
287
+ // Public product routes are /product/<slug> (singular) — see getProductPublicPath in
288
+ // lib/visual-editing/product-drafts.ts.
289
+ if (before?.slug) revalidatePath(`/product/${before.slug}`);
290
+ if (after?.slug && after.slug !== before?.slug) revalidatePath(`/product/${after.slug}`);
291
+
292
+ return result;
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Compare
297
+ // ---------------------------------------------------------------------------
298
+
299
+ type CompareResponse<T> = { success: true; current: T; target: T } | { error: string };
300
+
301
+ export async function comparePageVersion(pageId: number, targetVersion: number): Promise<CompareResponse<FullPageContent>> {
302
+ const userId = await requireUserId();
303
+ if (!userId) return { error: 'User not authenticated.' } as const;
304
+
305
+ const current = await getFullPageContent(pageId);
306
+ if (!current) return { error: 'Failed to fetch current content.' } as const;
307
+ const reconstructed = await reconstructPageVersionContent(pageId, targetVersion);
308
+ if ('error' in reconstructed) return { error: reconstructed.error } as const;
309
+ return { success: true as const, current, target: reconstructed.content };
310
+ }
311
+
312
+ export async function comparePostVersion(postId: number, targetVersion: number): Promise<CompareResponse<FullPostContent>> {
313
+ const userId = await requireUserId();
314
+ if (!userId) return { error: 'User not authenticated.' } as const;
315
+
316
+ const current = await getFullPostContent(postId);
317
+ if (!current) return { error: 'Failed to fetch current content.' } as const;
318
+ const reconstructed = await reconstructPostVersionContent(postId, targetVersion);
319
+ if ('error' in reconstructed) return { error: reconstructed.error } as const;
320
+ return { success: true as const, current, target: reconstructed.content };
321
+ }
322
+
323
+ export async function compareProductVersion(productId: string, targetVersion: number): Promise<CompareResponse<FullProductContent>> {
324
+ const userId = await requireUserId();
325
+ if (!userId) return { error: 'User not authenticated.' } as const;
326
+
327
+ const current = await getFullProductContent(productId);
328
+ if (!current) return { error: 'Failed to fetch current content.' } as const;
329
+ const reconstructed = await reconstructProductVersionContent(productId, targetVersion);
330
+ if ('error' in reconstructed) return { error: reconstructed.error } as const;
331
+ return { success: true as const, current, target: reconstructed.content };
332
+ }