create-nextblock 0.0.1

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 (206) hide show
  1. package/bin/create-nextblock.js +997 -0
  2. package/package.json +25 -0
  3. package/scripts/sync-template.js +284 -0
  4. package/templates/nextblock-template/.env.example +37 -0
  5. package/templates/nextblock-template/.swcrc +30 -0
  6. package/templates/nextblock-template/README.md +194 -0
  7. package/templates/nextblock-template/app/(auth-pages)/forgot-password/page.tsx +57 -0
  8. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -0
  9. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +28 -0
  10. package/templates/nextblock-template/app/(auth-pages)/sign-in/page.tsx +67 -0
  11. package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +70 -0
  12. package/templates/nextblock-template/app/ToasterProvider.tsx +17 -0
  13. package/templates/nextblock-template/app/[slug]/PageClientContent.tsx +147 -0
  14. package/templates/nextblock-template/app/[slug]/page.tsx +145 -0
  15. package/templates/nextblock-template/app/[slug]/page.utils.ts +183 -0
  16. package/templates/nextblock-template/app/actions/email.ts +31 -0
  17. package/templates/nextblock-template/app/actions/formActions.ts +65 -0
  18. package/templates/nextblock-template/app/actions/languageActions.ts +130 -0
  19. package/templates/nextblock-template/app/actions/postActions.ts +80 -0
  20. package/templates/nextblock-template/app/actions.ts +146 -0
  21. package/templates/nextblock-template/app/api/process-image/route.ts +210 -0
  22. package/templates/nextblock-template/app/api/revalidate/route.ts +86 -0
  23. package/templates/nextblock-template/app/api/revalidate-log/route.ts +23 -0
  24. package/templates/nextblock-template/app/api/upload/presigned-url/route.ts +106 -0
  25. package/templates/nextblock-template/app/api/upload/proxy/route.ts +84 -0
  26. package/templates/nextblock-template/app/auth/callback/route.ts +58 -0
  27. package/templates/nextblock-template/app/blog/[slug]/PostClientContent.tsx +169 -0
  28. package/templates/nextblock-template/app/blog/[slug]/page.tsx +177 -0
  29. package/templates/nextblock-template/app/blog/[slug]/page.utils.ts +136 -0
  30. package/templates/nextblock-template/app/blog/page.tsx +77 -0
  31. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +321 -0
  32. package/templates/nextblock-template/app/cms/blocks/actions.ts +434 -0
  33. package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +348 -0
  34. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +567 -0
  35. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +98 -0
  36. package/templates/nextblock-template/app/cms/blocks/components/BlockTypeCard.tsx +58 -0
  37. package/templates/nextblock-template/app/cms/blocks/components/BlockTypeSelector.tsx +62 -0
  38. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +276 -0
  39. package/templates/nextblock-template/app/cms/blocks/components/DeleteBlockButtonClient.tsx +47 -0
  40. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +182 -0
  41. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +120 -0
  42. package/templates/nextblock-template/app/cms/blocks/components/SectionConfigPanel.tsx +133 -0
  43. package/templates/nextblock-template/app/cms/blocks/components/SortableBlockItem.tsx +46 -0
  44. package/templates/nextblock-template/app/cms/blocks/editors/ButtonBlockEditor.tsx +85 -0
  45. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +182 -0
  46. package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +111 -0
  47. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +150 -0
  48. package/templates/nextblock-template/app/cms/blocks/editors/PostsGridBlockEditor.tsx +79 -0
  49. package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +337 -0
  50. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +81 -0
  51. package/templates/nextblock-template/app/cms/blocks/editors/VideoEmbedBlockEditor.tsx +64 -0
  52. package/templates/nextblock-template/app/cms/components/ConfirmationModal.tsx +51 -0
  53. package/templates/nextblock-template/app/cms/components/ContentLanguageSwitcher.tsx +145 -0
  54. package/templates/nextblock-template/app/cms/components/CopyContentFromLanguage.tsx +203 -0
  55. package/templates/nextblock-template/app/cms/components/LanguageFilterSelect.tsx +69 -0
  56. package/templates/nextblock-template/app/cms/dashboard/page.tsx +247 -0
  57. package/templates/nextblock-template/app/cms/layout.tsx +10 -0
  58. package/templates/nextblock-template/app/cms/media/UploadFolderContext.tsx +22 -0
  59. package/templates/nextblock-template/app/cms/media/[id]/edit/page.tsx +80 -0
  60. package/templates/nextblock-template/app/cms/media/actions.ts +577 -0
  61. package/templates/nextblock-template/app/cms/media/components/DeleteMediaButtonClient.tsx +53 -0
  62. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -0
  63. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -0
  64. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +157 -0
  65. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +275 -0
  66. package/templates/nextblock-template/app/cms/media/components/MediaImage.tsx +70 -0
  67. package/templates/nextblock-template/app/cms/media/components/MediaPickerDialog.tsx +195 -0
  68. package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +362 -0
  69. package/templates/nextblock-template/app/cms/media/page.tsx +120 -0
  70. package/templates/nextblock-template/app/cms/navigation/[id]/edit/page.tsx +101 -0
  71. package/templates/nextblock-template/app/cms/navigation/actions.ts +358 -0
  72. package/templates/nextblock-template/app/cms/navigation/components/DeleteNavItemButton.tsx +52 -0
  73. package/templates/nextblock-template/app/cms/navigation/components/NavigationItemForm.tsx +248 -0
  74. package/templates/nextblock-template/app/cms/navigation/components/NavigationLanguageSwitcher.tsx +132 -0
  75. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +701 -0
  76. package/templates/nextblock-template/app/cms/navigation/components/SortableNavItem.tsx +98 -0
  77. package/templates/nextblock-template/app/cms/navigation/new/page.tsx +26 -0
  78. package/templates/nextblock-template/app/cms/navigation/page.tsx +102 -0
  79. package/templates/nextblock-template/app/cms/navigation/utils.ts +51 -0
  80. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +121 -0
  81. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +79 -0
  82. package/templates/nextblock-template/app/cms/pages/actions.ts +241 -0
  83. package/templates/nextblock-template/app/cms/pages/components/DeletePageButtonClient.tsx +47 -0
  84. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +253 -0
  85. package/templates/nextblock-template/app/cms/pages/new/page.tsx +52 -0
  86. package/templates/nextblock-template/app/cms/pages/page.tsx +232 -0
  87. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +183 -0
  88. package/templates/nextblock-template/app/cms/posts/actions.ts +309 -0
  89. package/templates/nextblock-template/app/cms/posts/components/DeletePostButtonClient.tsx +55 -0
  90. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +419 -0
  91. package/templates/nextblock-template/app/cms/posts/new/page.tsx +21 -0
  92. package/templates/nextblock-template/app/cms/posts/page.tsx +192 -0
  93. package/templates/nextblock-template/app/cms/revisions/JsonDiffView.tsx +86 -0
  94. package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +201 -0
  95. package/templates/nextblock-template/app/cms/revisions/actions.ts +84 -0
  96. package/templates/nextblock-template/app/cms/revisions/service.ts +344 -0
  97. package/templates/nextblock-template/app/cms/revisions/utils.ts +127 -0
  98. package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +68 -0
  99. package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +78 -0
  100. package/templates/nextblock-template/app/cms/settings/copyright/page.tsx +32 -0
  101. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +117 -0
  102. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +216 -0
  103. package/templates/nextblock-template/app/cms/settings/languages/[id]/edit/page.tsx +77 -0
  104. package/templates/nextblock-template/app/cms/settings/languages/actions.ts +261 -0
  105. package/templates/nextblock-template/app/cms/settings/languages/components/DeleteLanguageButton.tsx +76 -0
  106. package/templates/nextblock-template/app/cms/settings/languages/components/LanguageForm.tsx +167 -0
  107. package/templates/nextblock-template/app/cms/settings/languages/new/page.tsx +34 -0
  108. package/templates/nextblock-template/app/cms/settings/languages/page.tsx +156 -0
  109. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +19 -0
  110. package/templates/nextblock-template/app/cms/settings/logos/actions.ts +114 -0
  111. package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +177 -0
  112. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +11 -0
  113. package/templates/nextblock-template/app/cms/settings/logos/page.tsx +118 -0
  114. package/templates/nextblock-template/app/cms/settings/logos/types.ts +8 -0
  115. package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +91 -0
  116. package/templates/nextblock-template/app/cms/users/actions.ts +156 -0
  117. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +71 -0
  118. package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +138 -0
  119. package/templates/nextblock-template/app/cms/users/page.tsx +183 -0
  120. package/templates/nextblock-template/app/favicon.ico +0 -0
  121. package/templates/nextblock-template/app/globals.css +401 -0
  122. package/templates/nextblock-template/app/layout.tsx +191 -0
  123. package/templates/nextblock-template/app/lib/sitemap-utils.ts +68 -0
  124. package/templates/nextblock-template/app/page.tsx +109 -0
  125. package/templates/nextblock-template/app/providers.tsx +43 -0
  126. package/templates/nextblock-template/app/robots.txt/route.ts +19 -0
  127. package/templates/nextblock-template/app/sitemap.xml/route.ts +63 -0
  128. package/templates/nextblock-template/app/unauthorized/page.tsx +27 -0
  129. package/templates/nextblock-template/backup/backup_2025-06-19.sql +8057 -0
  130. package/templates/nextblock-template/backup/backup_2025-06-20.sql +8159 -0
  131. package/templates/nextblock-template/backup/backup_2025-07-08.sql +8411 -0
  132. package/templates/nextblock-template/backup/backup_2025-07-09.sql +8442 -0
  133. package/templates/nextblock-template/backup/backup_2025-07-10.sql +8442 -0
  134. package/templates/nextblock-template/backup/backup_2025-10-01.sql +8803 -0
  135. package/templates/nextblock-template/backup/backup_2025-10-02.sql +9749 -0
  136. package/templates/nextblock-template/components/BlockRenderer.tsx +119 -0
  137. package/templates/nextblock-template/components/FooterNavigation.tsx +33 -0
  138. package/templates/nextblock-template/components/Header.tsx +42 -0
  139. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -0
  140. package/templates/nextblock-template/components/LanguageSwitcher.tsx +103 -0
  141. package/templates/nextblock-template/components/ResponsiveNav.tsx +372 -0
  142. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +17 -0
  143. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +93 -0
  144. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +180 -0
  145. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -0
  146. package/templates/nextblock-template/components/blocks/renderers/ClientTextBlockRenderer.tsx +69 -0
  147. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +98 -0
  148. package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +41 -0
  149. package/templates/nextblock-template/components/blocks/renderers/HeroBlockRenderer.tsx +240 -0
  150. package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +79 -0
  151. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +33 -0
  152. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +189 -0
  153. package/templates/nextblock-template/components/blocks/renderers/TextBlockRenderer.tsx +31 -0
  154. package/templates/nextblock-template/components/blocks/renderers/VideoEmbedBlockRenderer.tsx +59 -0
  155. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +51 -0
  156. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +40 -0
  157. package/templates/nextblock-template/components/blocks/types.ts +8 -0
  158. package/templates/nextblock-template/components/env-var-warning.tsx +33 -0
  159. package/templates/nextblock-template/components/form-message.tsx +26 -0
  160. package/templates/nextblock-template/components/header-auth.tsx +71 -0
  161. package/templates/nextblock-template/components/submit-button.tsx +23 -0
  162. package/templates/nextblock-template/components/theme-switcher.tsx +78 -0
  163. package/templates/nextblock-template/context/AuthContext.tsx +138 -0
  164. package/templates/nextblock-template/context/CurrentContentContext.tsx +42 -0
  165. package/templates/nextblock-template/context/LanguageContext.tsx +206 -0
  166. package/templates/nextblock-template/docs/cms-application-overview.md +56 -0
  167. package/templates/nextblock-template/docs/cms-architecture-overview.md +73 -0
  168. package/templates/nextblock-template/docs/files-structure.md +426 -0
  169. package/templates/nextblock-template/docs/tiptap-bundle-optimization-summary.md +174 -0
  170. package/templates/nextblock-template/eslint.config.mjs +28 -0
  171. package/templates/nextblock-template/index.d.ts +5 -0
  172. package/templates/nextblock-template/lib/blocks/README.md +670 -0
  173. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +1001 -0
  174. package/templates/nextblock-template/lib/ui/ColorPicker.ts +1 -0
  175. package/templates/nextblock-template/lib/ui/ConfirmationDialog.ts +1 -0
  176. package/templates/nextblock-template/lib/ui/CustomSelectWithInput.ts +1 -0
  177. package/templates/nextblock-template/lib/ui/Skeleton.ts +1 -0
  178. package/templates/nextblock-template/lib/ui/avatar.ts +1 -0
  179. package/templates/nextblock-template/lib/ui/badge.ts +1 -0
  180. package/templates/nextblock-template/lib/ui/button.ts +1 -0
  181. package/templates/nextblock-template/lib/ui/card.ts +1 -0
  182. package/templates/nextblock-template/lib/ui/checkbox.ts +1 -0
  183. package/templates/nextblock-template/lib/ui/dialog.ts +1 -0
  184. package/templates/nextblock-template/lib/ui/dropdown-menu.ts +1 -0
  185. package/templates/nextblock-template/lib/ui/input.ts +1 -0
  186. package/templates/nextblock-template/lib/ui/label.ts +1 -0
  187. package/templates/nextblock-template/lib/ui/popover.ts +1 -0
  188. package/templates/nextblock-template/lib/ui/progress.ts +1 -0
  189. package/templates/nextblock-template/lib/ui/select.ts +1 -0
  190. package/templates/nextblock-template/lib/ui/separator.ts +1 -0
  191. package/templates/nextblock-template/lib/ui/table.ts +1 -0
  192. package/templates/nextblock-template/lib/ui/textarea.ts +1 -0
  193. package/templates/nextblock-template/lib/ui/tooltip.ts +1 -0
  194. package/templates/nextblock-template/lib/ui/ui.ts +1 -0
  195. package/templates/nextblock-template/middleware.ts +206 -0
  196. package/templates/nextblock-template/next-env.d.ts +6 -0
  197. package/templates/nextblock-template/next.config.js +99 -0
  198. package/templates/nextblock-template/package.json +52 -0
  199. package/templates/nextblock-template/postcss.config.js +6 -0
  200. package/templates/nextblock-template/project.json +7 -0
  201. package/templates/nextblock-template/public/.gitkeep +0 -0
  202. package/templates/nextblock-template/scripts/backfill-image-meta.ts +149 -0
  203. package/templates/nextblock-template/scripts/backup.js +53 -0
  204. package/templates/nextblock-template/scripts/test-bundle-optimization.js +114 -0
  205. package/templates/nextblock-template/tailwind.config.ts +19 -0
  206. package/templates/nextblock-template/tsconfig.json +62 -0
@@ -0,0 +1,344 @@
1
+ // apps/nextblock/app/cms/revisions/service.ts
2
+ "use server";
3
+
4
+ import { createClient } from "@nextblock-cms/db/server";
5
+ import type { Json } from "@nextblock-cms/db";
6
+ import { compare, applyPatch } from 'fast-json-patch';
7
+ import type { FullPageContent, FullPostContent } from './utils';
8
+
9
+
10
+ function shouldCreateSnapshot(currentVersion: number): boolean {
11
+ // Create a snapshot every 20 revisions
12
+ return currentVersion % 20 === 0;
13
+ }
14
+
15
+ export async function createPageRevision(
16
+ pageId: number,
17
+ authorId: string,
18
+ previousContent: FullPageContent,
19
+ newContent: FullPageContent
20
+ ) {
21
+ const supabase = createClient();
22
+
23
+ // Get current version
24
+ const { data: page, error: pageError } = await supabase
25
+ .from('pages')
26
+ .select('version')
27
+ .eq('id', pageId)
28
+ .single();
29
+ if (pageError || !page) return { error: 'Page not found' } as const;
30
+
31
+ const nextVersion = (page.version ?? 1) + 1;
32
+ const makeSnapshot = shouldCreateSnapshot(page.version ?? 1) || nextVersion === 2; // ensure early snapshot cadence
33
+
34
+ const revisionType: 'snapshot' | 'diff' = makeSnapshot ? 'snapshot' : 'diff';
35
+ const content: Json = makeSnapshot ? (newContent as unknown as Json) : (compare(previousContent, newContent) as unknown as Json);
36
+
37
+ const { error: insertError } = await supabase.from('page_revisions').insert({
38
+ page_id: pageId,
39
+ author_id: authorId,
40
+ version: nextVersion,
41
+ revision_type: revisionType,
42
+ content,
43
+ });
44
+ if (insertError) return { error: `Failed to insert page revision: ${insertError.message}` } as const;
45
+
46
+ const { error: updateVersionError } = await supabase
47
+ .from('pages')
48
+ .update({ version: nextVersion })
49
+ .eq('id', pageId);
50
+ if (updateVersionError) return { error: `Failed to bump page version: ${updateVersionError.message}` } as const;
51
+
52
+ return { success: true as const, version: nextVersion };
53
+ }
54
+
55
+ export async function createPostRevision(
56
+ postId: number,
57
+ authorId: string,
58
+ previousContent: FullPostContent,
59
+ newContent: FullPostContent
60
+ ) {
61
+ const supabase = createClient();
62
+
63
+ const { data: post, error: postError } = await supabase
64
+ .from('posts')
65
+ .select('version')
66
+ .eq('id', postId)
67
+ .single();
68
+ if (postError || !post) return { error: 'Post not found' } as const;
69
+
70
+ const nextVersion = (post.version ?? 1) + 1;
71
+ const makeSnapshot = shouldCreateSnapshot(post.version ?? 1) || nextVersion === 2;
72
+
73
+ const revisionType: 'snapshot' | 'diff' = makeSnapshot ? 'snapshot' : 'diff';
74
+ const content: Json = makeSnapshot ? (newContent as unknown as Json) : (compare(previousContent, newContent) as unknown as Json);
75
+
76
+ const { error: insertError } = await supabase.from('post_revisions').insert({
77
+ post_id: postId,
78
+ author_id: authorId,
79
+ version: nextVersion,
80
+ revision_type: revisionType,
81
+ content,
82
+ });
83
+ if (insertError) return { error: `Failed to insert post revision: ${insertError.message}` } as const;
84
+
85
+ const { error: updateVersionError } = await supabase
86
+ .from('posts')
87
+ .update({ version: nextVersion })
88
+ .eq('id', postId);
89
+ if (updateVersionError) return { error: `Failed to bump post version: ${updateVersionError.message}` } as const;
90
+
91
+ return { success: true as const, version: nextVersion };
92
+ }
93
+
94
+ export async function restorePageToVersion(pageId: number, targetVersion: number, authorId: string) {
95
+ const supabase = createClient();
96
+
97
+ // 1. Find latest snapshot at or before target
98
+ const { data: snapshot, error: snapshotError } = await supabase
99
+ .from('page_revisions')
100
+ .select('version, content, revision_type')
101
+ .eq('page_id', pageId)
102
+ .lte('version', targetVersion)
103
+ .eq('revision_type', 'snapshot')
104
+ .order('version', { ascending: false })
105
+ .limit(1)
106
+ .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
+
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;
120
+
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;
128
+ }
129
+ }
130
+
131
+ // Determine next version number (append a new revision for restored state)
132
+ const { data: pageRow } = await supabase
133
+ .from('pages')
134
+ .select('version')
135
+ .eq('id', pageId)
136
+ .single();
137
+ const newVersion = ((pageRow?.version as number | null) ?? 1) + 1;
138
+
139
+ // 3. Apply to DB: update page meta and replace blocks; bump to newVersion
140
+ const { error: updatePageError } = await supabase
141
+ .from('pages')
142
+ .update({
143
+ title: content.meta.title,
144
+ slug: content.meta.slug,
145
+ language_id: content.meta.language_id,
146
+ status: content.meta.status,
147
+ meta_title: content.meta.meta_title,
148
+ meta_description: content.meta.meta_description,
149
+ version: newVersion,
150
+ })
151
+ .eq('id', pageId);
152
+ if (updatePageError) return { error: `Failed to update page: ${updatePageError.message}` } as const;
153
+
154
+ // delete all existing blocks for this page then reinsert
155
+ const { error: deleteError } = await supabase.from('blocks').delete().eq('page_id', pageId);
156
+ if (deleteError) return { error: `Failed to clear blocks: ${deleteError.message}` } as const;
157
+
158
+ if (content.blocks.length > 0) {
159
+ const toInsert = content.blocks.map(b => ({
160
+ page_id: pageId,
161
+ post_id: null,
162
+ language_id: b.language_id,
163
+ block_type: b.block_type,
164
+ content: b.content,
165
+ order: b.order,
166
+ }));
167
+ const { error: insertError } = await supabase.from('blocks').insert(toInsert);
168
+ if (insertError) return { error: `Failed to insert blocks: ${insertError.message}` } as const;
169
+ }
170
+
171
+ // 4. Record a new snapshot revision representing the restored state at newVersion
172
+ const { error: revErr } = await supabase.from('page_revisions').insert({
173
+ page_id: pageId,
174
+ author_id: authorId,
175
+ version: newVersion,
176
+ revision_type: 'snapshot',
177
+ content: content as unknown as Json,
178
+ });
179
+ if (revErr) return { error: `Failed to write restored revision: ${revErr.message}` } as const;
180
+
181
+ return { success: true as const };
182
+ }
183
+
184
+ export async function restorePostToVersion(postId: number, targetVersion: number, authorId: string) {
185
+ const supabase = createClient();
186
+
187
+ const { data: snapshot, error: snapshotError } = await supabase
188
+ .from('post_revisions')
189
+ .select('version, content, revision_type')
190
+ .eq('post_id', postId)
191
+ .lte('version', targetVersion)
192
+ .eq('revision_type', 'snapshot')
193
+ .order('version', { ascending: false })
194
+ .limit(1)
195
+ .maybeSingle();
196
+ if (snapshotError || !snapshot) return { error: 'No snapshot found at or before target version.' } as const;
197
+
198
+ let content = snapshot.content as unknown as FullPostContent;
199
+
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 });
207
+ if (diffsError) return { error: `Failed to fetch diffs: ${diffsError.message}` } as const;
208
+
209
+ for (const r of diffs || []) {
210
+ if (r.revision_type === 'diff') {
211
+ const ops = r.content as any[];
212
+ const result = applyPatch(content as any, ops, /*validate*/ false, /*mutateDocument*/ true);
213
+ content = result.newDocument as unknown as FullPostContent;
214
+ } else {
215
+ content = r.content as unknown as FullPostContent;
216
+ }
217
+ }
218
+
219
+ // Determine next version for post
220
+ const { data: postRow } = await supabase
221
+ .from('posts')
222
+ .select('version')
223
+ .eq('id', postId)
224
+ .single();
225
+ const newVersion = ((postRow?.version as number | null) ?? 1) + 1;
226
+
227
+ const { error: updatePostError } = await supabase
228
+ .from('posts')
229
+ .update({
230
+ title: content.meta.title,
231
+ slug: content.meta.slug,
232
+ language_id: content.meta.language_id,
233
+ status: content.meta.status,
234
+ meta_title: content.meta.meta_title,
235
+ meta_description: content.meta.meta_description,
236
+ excerpt: content.meta.excerpt,
237
+ published_at: content.meta.published_at,
238
+ feature_image_id: content.meta.feature_image_id,
239
+ version: newVersion,
240
+ })
241
+ .eq('id', postId);
242
+ if (updatePostError) return { error: `Failed to update post: ${updatePostError.message}` } as const;
243
+
244
+ const { error: deleteError } = await supabase.from('blocks').delete().eq('post_id', postId);
245
+ if (deleteError) return { error: `Failed to clear blocks: ${deleteError.message}` } as const;
246
+
247
+ if (content.blocks.length > 0) {
248
+ const toInsert = content.blocks.map(b => ({
249
+ page_id: null,
250
+ post_id: postId,
251
+ language_id: b.language_id,
252
+ block_type: b.block_type,
253
+ content: b.content,
254
+ order: b.order,
255
+ }));
256
+ const { error: insertError } = await supabase.from('blocks').insert(toInsert);
257
+ if (insertError) return { error: `Failed to insert blocks: ${insertError.message}` } as const;
258
+ }
259
+
260
+ const { error: revErr } = await supabase.from('post_revisions').insert({
261
+ post_id: postId,
262
+ author_id: authorId,
263
+ version: newVersion,
264
+ revision_type: 'snapshot',
265
+ content: content as unknown as Json,
266
+ });
267
+ if (revErr) return { error: `Failed to write restored revision: ${revErr.message}` } as const;
268
+
269
+ return { success: true as const };
270
+ }
271
+
272
+ export async function reconstructPageVersionContent(pageId: number, targetVersion: number) {
273
+ const supabase = createClient();
274
+
275
+ const { data: snapshot, error: snapshotError } = await supabase
276
+ .from('page_revisions')
277
+ .select('version, content, revision_type')
278
+ .eq('page_id', pageId)
279
+ .lte('version', targetVersion)
280
+ .eq('revision_type', 'snapshot')
281
+ .order('version', { ascending: false })
282
+ .limit(1)
283
+ .maybeSingle();
284
+ if (snapshotError || !snapshot) return { error: 'No snapshot found at or before target version.' } as const;
285
+
286
+ let content = snapshot.content as unknown as FullPageContent;
287
+
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 });
295
+ if (diffsError) return { error: `Failed to fetch diffs: ${diffsError.message}` } as const;
296
+
297
+ for (const r of diffs || []) {
298
+ if (r.revision_type === 'diff') {
299
+ const ops = r.content as any[];
300
+ const result = applyPatch(content as any, ops, false, true);
301
+ content = result.newDocument as unknown as FullPageContent;
302
+ } else {
303
+ content = r.content as unknown as FullPageContent;
304
+ }
305
+ }
306
+ return { success: true as const, content };
307
+ }
308
+
309
+ export async function reconstructPostVersionContent(postId: number, targetVersion: number) {
310
+ const supabase = createClient();
311
+
312
+ const { data: snapshot, error: snapshotError } = await supabase
313
+ .from('post_revisions')
314
+ .select('version, content, revision_type')
315
+ .eq('post_id', postId)
316
+ .lte('version', targetVersion)
317
+ .eq('revision_type', 'snapshot')
318
+ .order('version', { ascending: false })
319
+ .limit(1)
320
+ .maybeSingle();
321
+ if (snapshotError || !snapshot) return { error: 'No snapshot found at or before target version.' } as const;
322
+
323
+ let content = snapshot.content as unknown as FullPostContent;
324
+
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 });
332
+ if (diffsError) return { error: `Failed to fetch diffs: ${diffsError.message}` } as const;
333
+
334
+ for (const r of diffs || []) {
335
+ if (r.revision_type === 'diff') {
336
+ const ops = r.content as any[];
337
+ const result = applyPatch(content as any, ops, false, true);
338
+ content = result.newDocument as unknown as FullPostContent;
339
+ } else {
340
+ content = r.content as unknown as FullPostContent;
341
+ }
342
+ }
343
+ return { success: true as const, content };
344
+ }
@@ -0,0 +1,127 @@
1
+ // apps/nextblock/app/cms/revisions/utils.ts
2
+ import { createClient } from "@nextblock-cms/db/server";
3
+ import type { Database, Json } from "@nextblock-cms/db";
4
+
5
+ type BlockRow = Database['public']['Tables']['blocks']['Row'];
6
+
7
+ export interface PageMetaContent {
8
+ title: string;
9
+ slug: string;
10
+ language_id: number;
11
+ status: Database['public']['Enums']['page_status'];
12
+ meta_title: string | null;
13
+ meta_description: string | null;
14
+ }
15
+
16
+ export interface PostMetaContent extends PageMetaContent {
17
+ excerpt: string | null;
18
+ published_at: string | null;
19
+ feature_image_id: string | null;
20
+ }
21
+
22
+ export interface SimpleBlockContent {
23
+ language_id: number;
24
+ block_type: BlockRow['block_type'];
25
+ content: Json;
26
+ order: number;
27
+ }
28
+
29
+ export interface FullPageContent {
30
+ meta: PageMetaContent;
31
+ blocks: SimpleBlockContent[];
32
+ }
33
+
34
+ export interface FullPostContent {
35
+ meta: PostMetaContent;
36
+ blocks: SimpleBlockContent[];
37
+ }
38
+
39
+ export async function getFullPageContent(
40
+ pageId: number,
41
+ opts?: { overrideBlockId?: number; overrideBlockContent?: unknown; excludeDeletedBlockId?: number }
42
+ ): Promise<FullPageContent | null> {
43
+ const supabase = createClient();
44
+ const { data: page, error: pageError } = await supabase
45
+ .from('pages')
46
+ .select('id, title, slug, language_id, status, meta_title, meta_description')
47
+ .eq('id', pageId)
48
+ .single();
49
+ if (pageError || !page) return null;
50
+
51
+ const { data: blocks, error: blocksError } = await supabase
52
+ .from('blocks')
53
+ .select('id, language_id, block_type, content, order, page_id, post_id')
54
+ .eq('page_id', pageId)
55
+ .order('order', { ascending: true });
56
+ if (blocksError) return null;
57
+
58
+ const processed = (blocks || [])
59
+ .filter(b => opts?.excludeDeletedBlockId ? b.id !== opts.excludeDeletedBlockId : true)
60
+ .map(b => ({
61
+ language_id: b.language_id,
62
+ block_type: b.block_type,
63
+ content: (opts?.overrideBlockId && b.id === opts.overrideBlockId)
64
+ ? (opts.overrideBlockContent as Json)
65
+ : (b.content as Json),
66
+ order: b.order,
67
+ } satisfies SimpleBlockContent));
68
+
69
+ return {
70
+ meta: {
71
+ title: page.title,
72
+ slug: page.slug,
73
+ language_id: page.language_id,
74
+ status: page.status,
75
+ meta_title: page.meta_title,
76
+ meta_description: page.meta_description,
77
+ },
78
+ blocks: processed,
79
+ };
80
+ }
81
+
82
+ export async function getFullPostContent(
83
+ postId: number,
84
+ opts?: { overrideBlockId?: number; overrideBlockContent?: unknown; excludeDeletedBlockId?: number }
85
+ ): Promise<FullPostContent | null> {
86
+ const supabase = createClient();
87
+ const { data: post, error: postError } = await supabase
88
+ .from('posts')
89
+ .select('id, title, slug, language_id, status, meta_title, meta_description, excerpt, published_at, feature_image_id')
90
+ .eq('id', postId)
91
+ .single();
92
+ if (postError || !post) return null;
93
+
94
+ const { data: blocks, error: blocksError } = await supabase
95
+ .from('blocks')
96
+ .select('id, language_id, block_type, content, order, page_id, post_id')
97
+ .eq('post_id', postId)
98
+ .order('order', { ascending: true });
99
+ if (blocksError) return null;
100
+
101
+ const processed = (blocks || [])
102
+ .filter(b => opts?.excludeDeletedBlockId ? b.id !== opts.excludeDeletedBlockId : true)
103
+ .map(b => ({
104
+ language_id: b.language_id,
105
+ block_type: b.block_type,
106
+ content: (opts?.overrideBlockId && b.id === opts.overrideBlockId)
107
+ ? (opts.overrideBlockContent as Json)
108
+ : (b.content as Json),
109
+ order: b.order,
110
+ } satisfies SimpleBlockContent));
111
+
112
+ return {
113
+ meta: {
114
+ title: post.title,
115
+ slug: post.slug,
116
+ language_id: post.language_id,
117
+ status: post.status,
118
+ meta_title: post.meta_title,
119
+ meta_description: post.meta_description,
120
+ excerpt: post.excerpt,
121
+ published_at: post.published_at ? new Date(post.published_at).toISOString() : null,
122
+ feature_image_id: post.feature_image_id ?? null,
123
+ },
124
+ blocks: processed,
125
+ };
126
+ }
127
+
@@ -0,0 +1,68 @@
1
+ // app/cms/settings/copyright/actions.ts
2
+ 'use server';
3
+
4
+ import { createClient } from '@nextblock-cms/db/server';
5
+ import { revalidatePath } from 'next/cache';
6
+
7
+ export type CopyrightSettings = {
8
+ [key: string]: string;
9
+ };
10
+
11
+ export async function getCopyrightSettings(): Promise<CopyrightSettings> {
12
+ const supabase = createClient();
13
+ const { data, error } = await supabase
14
+ .from('site_settings')
15
+ .select('value')
16
+ .eq('key', 'footer_copyright')
17
+ .single();
18
+
19
+ if (error || !data) {
20
+ // If you want to handle the "not found" case gracefully,
21
+ // you could return a default value instead of throwing an error.
22
+ console.error('Copyright settings not found:', error);
23
+ return { en: '© {year} Default Copyright. All rights reserved.' };
24
+ }
25
+
26
+ return data.value as CopyrightSettings;
27
+ }
28
+
29
+ export async function updateCopyrightSettings(formData: FormData) {
30
+ const supabase = createClient();
31
+
32
+ // Check if user is an admin
33
+ const { data: { user } } = await supabase.auth.getUser();
34
+ if (!user) {
35
+ throw new Error('You must be logged in to update settings.');
36
+ }
37
+ const { data: profile, error: profileError } = await supabase
38
+ .from('profiles')
39
+ .select('role')
40
+ .eq('id', user.id)
41
+ .single();
42
+
43
+ if (profileError || !profile || !['ADMIN', 'WRITER'].includes(profile.role)) {
44
+ throw new Error('You do not have permission to perform this action.');
45
+ }
46
+
47
+ const newSettings: CopyrightSettings = {};
48
+ formData.forEach((value, key) => {
49
+ if (key.startsWith('copyright_')) {
50
+ const langCode = key.replace('copyright_', '');
51
+ newSettings[langCode] = value as string;
52
+ }
53
+ });
54
+
55
+ const { error } = await supabase
56
+ .from('site_settings')
57
+ .upsert({ key: 'footer_copyright', value: newSettings });
58
+
59
+ if (error) {
60
+ console.error('Error updating copyright settings:', error);
61
+ throw new Error('Failed to update copyright settings.');
62
+ }
63
+
64
+ // Revalidate the root layout to reflect changes immediately across the site.
65
+ revalidatePath('/', 'layout');
66
+
67
+ return { success: true, message: 'Copyright settings updated successfully.' };
68
+ }
@@ -0,0 +1,78 @@
1
+ 'use client';
2
+
3
+ import { useState, useTransition } from 'react';
4
+ import type { Database } from '@nextblock-cms/db';
5
+
6
+ type Language = Database['public']['Tables']['languages']['Row'];
7
+ import { CopyrightSettings, updateCopyrightSettings } from '../actions';
8
+ import { Label } from '@nextblock-cms/ui';
9
+ import { Input } from '@nextblock-cms/ui';
10
+ import { Button } from '@nextblock-cms/ui';
11
+ import { FormMessage, type Message } from '@/components/form-message';
12
+
13
+ interface CopyrightFormProps {
14
+ languages: Language[];
15
+ initialSettings: CopyrightSettings;
16
+ }
17
+
18
+ export default function CopyrightForm({ languages, initialSettings }: CopyrightFormProps) {
19
+ const [isPending, startTransition] = useTransition();
20
+ const [message, setMessage] = useState<Message | null>(null);
21
+ const [settings, setSettings] = useState<CopyrightSettings>(initialSettings);
22
+
23
+ const handleInputChange = (langCode: string, value: string) => {
24
+ setSettings(prev => ({ ...prev, [langCode]: value }));
25
+ };
26
+
27
+ const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
28
+ event.preventDefault();
29
+ setMessage(null);
30
+
31
+ const formData = new FormData();
32
+ for (const lang of languages) {
33
+ const value = settings[lang.code] || '';
34
+ formData.append(`copyright_${lang.code}`, value);
35
+ }
36
+
37
+ startTransition(async () => {
38
+ try {
39
+ const result = await updateCopyrightSettings(formData);
40
+ if (result.success) {
41
+ setMessage({ success: result.message });
42
+ } else {
43
+ setMessage({ error: 'An unexpected error occurred.' });
44
+ }
45
+ } catch (error) {
46
+ setMessage({ error: error instanceof Error ? error.message : 'An unknown error occurred.' });
47
+ }
48
+ });
49
+ };
50
+
51
+ return (
52
+ <form onSubmit={handleSubmit} className="space-y-6">
53
+ <div className="space-y-4">
54
+ {languages.map(lang => (
55
+ <div key={lang.id} className="space-y-2">
56
+ <Label htmlFor={`copyright_${lang.code}`}>
57
+ {lang.name} ({lang.code})
58
+ </Label>
59
+ <Input
60
+ id={`copyright_${lang.code}`}
61
+ name={`copyright_${lang.code}`}
62
+ value={settings[lang.code] || ''}
63
+ onChange={(e) => handleInputChange(lang.code, e.target.value)}
64
+ placeholder="e.g., © {year} Copyright"
65
+ />
66
+ </div>
67
+ ))}
68
+ </div>
69
+
70
+ <div className="flex items-center gap-4">
71
+ <Button type="submit" disabled={isPending}>
72
+ {isPending ? 'Saving...' : 'Save Settings'}
73
+ </Button>
74
+ {message && <FormMessage message={message} />}
75
+ </div>
76
+ </form>
77
+ );
78
+ }
@@ -0,0 +1,32 @@
1
+ // app/cms/settings/copyright/page.tsx
2
+ import { getActiveLanguagesServerSide } from '@/app/cms/settings/languages/actions';
3
+ import { getCopyrightSettings } from './actions';
4
+ import CopyrightForm from './components/CopyrightForm';
5
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@nextblock-cms/ui';
6
+
7
+ export default async function CopyrightSettingsPage() {
8
+ const languages = await getActiveLanguagesServerSide();
9
+ const copyrightSettings = await getCopyrightSettings();
10
+
11
+ const year = new Date().getFullYear();
12
+
13
+ return (
14
+ <div className="max-w-4xl mx-auto">
15
+ <Card>
16
+ <CardHeader>
17
+ <CardTitle>Footer Copyright Settings</CardTitle>
18
+ <CardDescription>
19
+ Manage the copyright text displayed in the site footer for each language.
20
+ Use &quot;{year}&quot; as a placeholder for the current year.
21
+ </CardDescription>
22
+ </CardHeader>
23
+ <CardContent>
24
+ <CopyrightForm
25
+ languages={languages}
26
+ initialSettings={copyrightSettings}
27
+ />
28
+ </CardContent>
29
+ </Card>
30
+ </div>
31
+ );
32
+ }