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,358 @@
1
+ // app/cms/navigation/actions.ts
2
+ "use server";
3
+
4
+ import { createClient } from "@nextblock-cms/db/server";
5
+ import { revalidatePath, unstable_noStore } from "next/cache";
6
+ import { redirect } from "next/navigation";
7
+ import type { Database } from "@nextblock-cms/db";
8
+ import { v4 as uuidv4 } from 'uuid';
9
+
10
+ type NavigationItem = Database['public']['Tables']['navigation_items']['Row'];
11
+ type MenuLocation = Database['public']['Enums']['menu_location'];
12
+
13
+ // Helper to check admin role
14
+ async function isAdminUser(supabase: ReturnType<typeof createClient>): Promise<boolean> {
15
+ const { data: { user } } = await supabase.auth.getUser();
16
+ if (!user) return false;
17
+ const { data: profile } = await supabase
18
+ .from("profiles")
19
+ .select("role")
20
+ .eq("id", user.id)
21
+ .single();
22
+ return profile?.role === "ADMIN";
23
+ }
24
+
25
+ type UpsertNavigationItemPayload = {
26
+ language_id: number;
27
+ menu_key: MenuLocation;
28
+ label: string;
29
+ url: string;
30
+ parent_id?: number | null;
31
+ order?: number;
32
+ page_id?: number | null;
33
+ translation_group_id: string;
34
+ };
35
+
36
+ // Helper to generate a placeholder label for new translations
37
+ function generatePlaceholderLabel(originalLabel: string, langCode: string): string {
38
+ return `[${langCode.toUpperCase()}] ${originalLabel}`;
39
+ }
40
+
41
+
42
+ export async function createNavigationItem(formData: FormData) {
43
+ const supabase = createClient();
44
+
45
+ if (!(await isAdminUser(supabase))) {
46
+ return { error: "Unauthorized: Admin role required." };
47
+ }
48
+
49
+ const fromGroupId = formData.get("from_translation_group_id") as string | null;
50
+ const targetLangIdForTranslation = formData.get("target_language_id_for_translation") as string | null;
51
+ const initialMenuKeyFromParam = formData.get("menu_key_from_param") as MenuLocation | null; // Capture if passed for new translation
52
+
53
+
54
+ const rawFormData = {
55
+ label: formData.get("label") as string,
56
+ url: formData.get("url") as string,
57
+ language_id: parseInt(formData.get("language_id") as string, 10),
58
+ menu_key: (initialMenuKeyFromParam || formData.get("menu_key")) as MenuLocation,
59
+ order: parseInt(formData.get("order") as string, 10) || 0,
60
+ parent_id: formData.get("parent_id") && formData.get("parent_id") !== "___NONE___" ? parseInt(formData.get("parent_id") as string, 10) : null,
61
+ page_id: formData.get("page_id") && formData.get("page_id") !== "___NONE___" ? parseInt(formData.get("page_id") as string, 10) : null,
62
+ };
63
+
64
+ if (!rawFormData.label || !rawFormData.url || isNaN(rawFormData.language_id) || !rawFormData.menu_key) {
65
+ return { error: "Missing required fields: label, URL, language, or menu key." };
66
+ }
67
+
68
+ const translationGroupId = fromGroupId || uuidv4();
69
+
70
+ const navData: UpsertNavigationItemPayload = {
71
+ ...rawFormData,
72
+ translation_group_id: translationGroupId,
73
+ };
74
+
75
+ const { data: newNavItem, error } = await supabase
76
+ .from("navigation_items")
77
+ .insert(navData)
78
+ .select()
79
+ .single();
80
+
81
+ if (error) {
82
+ console.error("Error creating navigation item:", error);
83
+ return { error: `Failed to create item: ${error.message}` };
84
+ }
85
+
86
+ let successMessage = "Navigation item created successfully.";
87
+
88
+ if (newNavItem && !fromGroupId && !targetLangIdForTranslation) {
89
+ // Get other languages to create placeholder translations
90
+ const { data: languages, error: langError } = await supabase
91
+ .from("languages")
92
+ .select("id, code")
93
+ .neq("id", newNavItem.language_id);
94
+
95
+ if (langError) {
96
+ console.error("Error fetching other languages for nav item auto-creation:", langError);
97
+ } else if (languages && languages.length > 0) {
98
+ let parentTranslationGroupId: string | null = null;
99
+ if (newNavItem.parent_id) {
100
+ const { data: parentItem, error: parentError } = await supabase
101
+ .from("navigation_items")
102
+ .select("translation_group_id")
103
+ .eq("id", newNavItem.parent_id)
104
+ .single();
105
+ if (parentError) {
106
+ console.error(`Error fetching parent translation group ID:`, parentError);
107
+ } else {
108
+ parentTranslationGroupId = parentItem.translation_group_id;
109
+ }
110
+ }
111
+
112
+ let pageTranslationGroupId: string | null = null;
113
+ if (newNavItem.page_id) {
114
+ const { data: linkedPage, error: pageError } = await supabase
115
+ .from("pages")
116
+ .select("translation_group_id")
117
+ .eq("id", newNavItem.page_id)
118
+ .single();
119
+ if (pageError) {
120
+ console.error(`Error fetching page translation group ID:`, pageError);
121
+ } else if (linkedPage) {
122
+ pageTranslationGroupId = linkedPage.translation_group_id;
123
+ }
124
+ }
125
+
126
+ let placeholderCreations = 0;
127
+ for (const lang of languages) {
128
+ let translatedParentId: number | null = null;
129
+ if (parentTranslationGroupId) {
130
+ const { data: translatedParent } = await supabase
131
+ .from("navigation_items")
132
+ .select("id")
133
+ .eq("translation_group_id", parentTranslationGroupId)
134
+ .eq("language_id", lang.id)
135
+ .single();
136
+ if (translatedParent) {
137
+ translatedParentId = translatedParent.id;
138
+ }
139
+ }
140
+
141
+ let translatedPageId: number | null = null;
142
+ if (pageTranslationGroupId) {
143
+ const { data: translatedPage } = await supabase
144
+ .from("pages")
145
+ .select("id")
146
+ .eq("translation_group_id", pageTranslationGroupId)
147
+ .eq("language_id", lang.id)
148
+ .single();
149
+ if (translatedPage) {
150
+ translatedPageId = translatedPage.id;
151
+ }
152
+ }
153
+
154
+ const placeholderNavItemData: UpsertNavigationItemPayload = {
155
+ language_id: lang.id,
156
+ menu_key: newNavItem.menu_key,
157
+ label: generatePlaceholderLabel(newNavItem.label, lang.code),
158
+ url: newNavItem.url,
159
+ parent_id: translatedParentId,
160
+ order: newNavItem.order,
161
+ page_id: translatedPageId,
162
+ translation_group_id: newNavItem.translation_group_id,
163
+ };
164
+
165
+ const { error: placeholderError } = await supabase.from("navigation_items").insert(placeholderNavItemData);
166
+ if (placeholderError) {
167
+ console.error(`Error auto-creating nav item for language ${lang.code}:`, placeholderError);
168
+ } else {
169
+ placeholderCreations++;
170
+ }
171
+ }
172
+
173
+ if (placeholderCreations > 0) {
174
+ successMessage += ` ${placeholderCreations} translated version(s) also created (please edit their details).`;
175
+ }
176
+ }
177
+ }
178
+
179
+
180
+ revalidatePath("/cms/navigation");
181
+ if (newNavItem?.id) {
182
+ revalidatePath(`/cms/navigation/${newNavItem.id}/edit`);
183
+ redirect(`/cms/navigation/${newNavItem.id}/edit?success=${encodeURIComponent(successMessage)}`);
184
+ } else {
185
+ redirect(`/cms/navigation?success=${encodeURIComponent(successMessage)}`);
186
+ }
187
+ }
188
+
189
+ export async function updateNavigationItem(itemId: number, formData: FormData) {
190
+ const supabase = createClient();
191
+
192
+ if (!(await isAdminUser(supabase))) {
193
+ return { error: "Unauthorized: Admin role required." };
194
+ }
195
+ const { data: existingItem, error: fetchError } = await supabase
196
+ .from("navigation_items")
197
+ .select("translation_group_id, language_id")
198
+ .eq("id", itemId)
199
+ .single();
200
+
201
+ if (fetchError || !existingItem) {
202
+ return { error: "Original navigation item not found or error fetching it." };
203
+ }
204
+
205
+ const rawFormData = {
206
+ label: formData.get("label") as string,
207
+ url: formData.get("url") as string,
208
+ language_id: parseInt(formData.get("language_id") as string, 10),
209
+ menu_key: formData.get("menu_key") as MenuLocation,
210
+ order: parseInt(formData.get("order") as string, 10) || 0,
211
+ parent_id: formData.get("parent_id") && formData.get("parent_id") !== "___NONE___" ? parseInt(formData.get("parent_id") as string, 10) : null,
212
+ page_id: formData.get("page_id") && formData.get("page_id") !== "___NONE___" ? parseInt(formData.get("page_id") as string, 10) : null,
213
+ };
214
+
215
+ if (!rawFormData.label || !rawFormData.url || isNaN(rawFormData.language_id) || !rawFormData.menu_key) {
216
+ return { error: "Missing required fields: label, URL, language, or menu key." };
217
+ }
218
+
219
+ if (rawFormData.language_id !== existingItem.language_id) {
220
+ return { error: "Changing the language of an existing navigation item version is not allowed. Create a new translation instead." };
221
+ }
222
+
223
+ const navData: Partial<Omit<UpsertNavigationItemPayload, 'translation_group_id'>> = {
224
+ ...rawFormData,
225
+ };
226
+
227
+ const { error } = await supabase
228
+ .from("navigation_items")
229
+ .update(navData)
230
+ .eq("id", itemId);
231
+
232
+ if (error) {
233
+ console.error("Error updating navigation item:", error);
234
+ return { error: `Failed to update item: ${error.message}` };
235
+ }
236
+
237
+ revalidatePath("/cms/navigation");
238
+ revalidatePath(`/cms/navigation/${itemId}/edit`);
239
+ redirect(`/cms/navigation/${itemId}/edit?success=Item updated successfully`);
240
+ }
241
+
242
+ export async function deleteNavigationItem(itemId: number) {
243
+ const supabase = createClient();
244
+
245
+ if (!(await isAdminUser(supabase))) {
246
+ return { error: "Unauthorized: Admin role required." };
247
+ }
248
+
249
+ // First, get the translation_group_id for the item being deleted
250
+ const { data: itemToDelete, error: fetchError } = await supabase
251
+ .from("navigation_items")
252
+ .select("translation_group_id")
253
+ .eq("id", itemId)
254
+ .single();
255
+
256
+ if (fetchError || !itemToDelete) {
257
+ console.error("Error finding navigation item to delete:", fetchError);
258
+ return { error: "Failed to find the navigation item to delete." };
259
+ }
260
+
261
+ const { translation_group_id } = itemToDelete;
262
+
263
+ if (!translation_group_id) {
264
+ console.error("Navigation item is missing a translation_group_id:", itemId);
265
+ return { error: "Cannot delete item as it is missing translation information." };
266
+ }
267
+
268
+ // Now, delete all items in the same translation group
269
+ const { error: deleteError } = await supabase
270
+ .from("navigation_items")
271
+ .delete()
272
+ .eq("translation_group_id", translation_group_id);
273
+
274
+ if (deleteError) {
275
+ console.error("Error deleting navigation item and its translations:", deleteError);
276
+ return { error: `Failed to delete item and its translations: ${deleteError.message}` };
277
+ }
278
+
279
+ revalidatePath("/cms/navigation");
280
+
281
+ return { success: true };
282
+ }
283
+
284
+
285
+ export async function updateNavigationStructureBatch(
286
+ itemsToUpdate: Array<{ id: number; order: number; parent_id: number | null; }>
287
+ ) {
288
+ const supabase = createClient();
289
+ if (!(await isAdminUser(supabase))) {
290
+ return { error: "Unauthorized: Admin role required for batch update." };
291
+ }
292
+
293
+ if (!itemsToUpdate || itemsToUpdate.length === 0) {
294
+ return { error: "No items provided for update." };
295
+ }
296
+
297
+ // Supabase JS v2 doesn't have built-in transactions for multiple upserts like this directly.
298
+ // You'd typically loop and perform individual updates.
299
+ // If one fails, others might have succeeded. Consider rollback strategy if needed (more complex).
300
+ let CmsNavigationListPageFailedUpdates = 0;
301
+ for (const item of itemsToUpdate) {
302
+ const { error } = await supabase
303
+ .from("navigation_items")
304
+ .update({
305
+ order: item.order,
306
+ parent_id: item.parent_id,
307
+ updated_at: new Date().toISOString(), // Also update updated_at
308
+ })
309
+ .eq("id", item.id);
310
+
311
+ if (error) {
312
+ console.error(`Error updating nav item ${item.id}:`, error.message);
313
+ CmsNavigationListPageFailedUpdates++;
314
+ }
315
+ }
316
+
317
+ if (CmsNavigationListPageFailedUpdates > 0) {
318
+ return { error: `Failed to update ${CmsNavigationListPageFailedUpdates} item(s). Some changes might not have been saved.` };
319
+ }
320
+
321
+ revalidatePath("/cms/navigation");
322
+ // No redirect needed here, as this is likely called via client-side transition
323
+ return { success: true, message: "Navigation structure updated." };
324
+ }
325
+
326
+
327
+ // Fetches navigation items for a specific menu and language (used by public site Header/Footer)
328
+ export async function getNavigationMenu(menuKey: MenuLocation, languageCode: string): Promise<NavigationItem[]> {
329
+ const supabase = createClient(); // server client
330
+ unstable_noStore(); // Opt out of caching for this function
331
+
332
+ const { data: language, error: langError } = await supabase
333
+ .from("languages")
334
+ .select("id")
335
+ .eq("code", languageCode)
336
+ .single();
337
+
338
+ if (langError || !language) {
339
+ console.error(`Error fetching language ID for code ${languageCode} in getNavigationMenu:`, langError);
340
+ return [];
341
+ }
342
+
343
+ const languageId = language.id;
344
+
345
+ const { data: items, error: itemsError } = await supabase
346
+ .from("navigation_items")
347
+ .select("*, pages(slug)") // Select all fields, including translation_group_id and linked page slug
348
+ .eq("menu_key", menuKey)
349
+ .eq("language_id", languageId)
350
+ .order("parent_id", { nullsFirst: true })
351
+ .order("order");
352
+
353
+ if (itemsError) {
354
+ console.error(`Error fetching navigation items for ${menuKey} (${languageCode}):`, itemsError);
355
+ return [];
356
+ }
357
+ return (items || []).map(item => ({...item, id: Number(item.id)}));
358
+ }
@@ -0,0 +1,52 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { DropdownMenuItem } from "@nextblock-cms/ui";
5
+ import { Trash2 } from "lucide-react";
6
+ import { deleteNavigationItem } from "../actions";
7
+ import { ConfirmationModal } from "../../components/ConfirmationModal";
8
+
9
+ interface DeleteNavItemButtonProps {
10
+ itemId: number;
11
+ }
12
+
13
+ export default function DeleteNavItemButton({ itemId }: DeleteNavItemButtonProps) {
14
+ const [isModalOpen, setIsModalOpen] = useState(false);
15
+
16
+ const onConfirm = async () => {
17
+ try {
18
+ const result = await deleteNavigationItem(itemId);
19
+ if (result.success) {
20
+ window.location.reload();
21
+ } else {
22
+ console.error("Delete operation failed:", result.error);
23
+ }
24
+ } catch (error) {
25
+ console.error("Exception during delete action:", error);
26
+ } finally {
27
+ setIsModalOpen(false);
28
+ }
29
+ };
30
+
31
+ return (
32
+ <>
33
+ <DropdownMenuItem
34
+ className="text-red-600 hover:!text-red-600 hover:!bg-red-50 dark:hover:!bg-red-700/20"
35
+ onSelect={(e) => {
36
+ e.preventDefault();
37
+ setIsModalOpen(true);
38
+ }}
39
+ >
40
+ <Trash2 className="mr-2 h-4 w-4" />
41
+ Delete
42
+ </DropdownMenuItem>
43
+ <ConfirmationModal
44
+ isOpen={isModalOpen}
45
+ onClose={() => setIsModalOpen(false)}
46
+ onConfirm={onConfirm}
47
+ title="Are you sure?"
48
+ description="This will permanently delete the navigation item. This action cannot be undone."
49
+ />
50
+ </>
51
+ );
52
+ }
@@ -0,0 +1,248 @@
1
+ // app/cms/navigation/components/NavigationItemForm.tsx
2
+ "use client";
3
+
4
+ import React, { useEffect, useState, useTransition } from "react";
5
+ import { useRouter, useSearchParams } from "next/navigation";
6
+ import { Button } from "@nextblock-cms/ui";
7
+ import { Input } from "@nextblock-cms/ui";
8
+ import { Label } from "@nextblock-cms/ui";
9
+ import {
10
+ Select,
11
+ SelectContent,
12
+ SelectItem,
13
+ SelectTrigger,
14
+ SelectValue,
15
+ } from "@nextblock-cms/ui";
16
+ import type { Database } from "@nextblock-cms/db";
17
+ import { useAuth } from "@/context/AuthContext";
18
+
19
+ type NavigationItem = Database['public']['Tables']['navigation_items']['Row'];
20
+ type MenuLocation = Database['public']['Enums']['menu_location'];
21
+ type Language = Database['public']['Tables']['languages']['Row'];
22
+ type Page = Database['public']['Tables']['pages']['Row'];
23
+
24
+ interface NavigationItemFormProps {
25
+ item?: NavigationItem | null;
26
+ formAction: (formData: FormData) => Promise<{ error?: string } | void>;
27
+ actionButtonText?: string;
28
+ isEditing?: boolean;
29
+ languages: Language[];
30
+ parentItems: (Pick<NavigationItem, 'id' | 'label' | 'translation_group_id' | 'language_id' | 'parent_id'> & { menu_key: MenuLocation | null })[];
31
+ pages: Pick<Page, 'id' | 'title' | 'slug' | 'language_id'>[];
32
+ }
33
+
34
+ export default function NavigationItemForm({
35
+ item,
36
+ formAction,
37
+ actionButtonText = "Save Item",
38
+ isEditing = false,
39
+ languages,
40
+ parentItems,
41
+ pages,
42
+ }: NavigationItemFormProps) {
43
+ const router = useRouter();
44
+ const searchParams = useSearchParams();
45
+ const [isPending, startTransition] = useTransition();
46
+ const { isAdmin, isLoading: authLoading } = useAuth();
47
+
48
+ // For creating a new translation based on an existing item
49
+ const fromTranslationGroupId = searchParams.get("from_translation_group_id");
50
+ const targetLanguageIdForTranslation = searchParams.get("target_language_id_for_translation");
51
+ const initialMenuKeyFromParam = searchParams.get("menu_key") as MenuLocation | null;
52
+ const originalLabelFromParam = searchParams.get("original_label");
53
+
54
+
55
+ const [label, setLabel] = useState(item?.label || (originalLabelFromParam ? `[Translate] ${originalLabelFromParam}` : ""));
56
+ const [url, setUrl] = useState(item?.url || (originalLabelFromParam ? "#" : "")); // Default to # if translating
57
+ const [languageId, setLanguageId] = useState<string>(
58
+ targetLanguageIdForTranslation || item?.language_id?.toString() || ""
59
+ );
60
+ const [menuKey, setMenuKey] = useState<MenuLocation | "">(
61
+ initialMenuKeyFromParam || item?.menu_key || "HEADER"
62
+ );
63
+ const [order, setOrder] = useState<string>(item?.order?.toString() || "0");
64
+ const [parentId, setParentId] = useState<string>(item?.parent_id?.toString() || "");
65
+ const [pageId, setPageId] = useState<string>(item?.page_id?.toString() || "");
66
+
67
+ const [availableLanguages] = useState<Language[]>(languages);
68
+ const [availablePages, setAvailablePages] = useState<Pick<Page, 'id' | 'title' | 'slug'>[]>([]);
69
+ const [availableParentItems, setAvailableParentItems] = useState<(Pick<NavigationItem, 'id' | 'label' | 'translation_group_id'> & { menu_key: MenuLocation | null })[]>([]);
70
+ const [dataLoading] = useState(false);
71
+ const [formMessage, setFormMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
72
+
73
+ useEffect(() => {
74
+ const successMessage = searchParams.get('success');
75
+ const errorMessage = searchParams.get('error');
76
+ if (successMessage) setFormMessage({ type: 'success', text: decodeURIComponent(successMessage) });
77
+ else if (errorMessage) setFormMessage({ type: 'error', text: decodeURIComponent(errorMessage) });
78
+ }, [searchParams]);
79
+
80
+ useEffect(() => {
81
+ if (!isEditing && !languageId && !targetLanguageIdForTranslation && languages.length > 0) {
82
+ const defaultLang = languages.find(l => l.is_default) || languages[0];
83
+ if (defaultLang) setLanguageId(defaultLang.id.toString());
84
+ }
85
+ }, [isEditing, languageId, targetLanguageIdForTranslation, languages]);
86
+
87
+ useEffect(() => {
88
+ const currentLangId = languageId ? parseInt(languageId, 10) : null;
89
+ if (currentLangId) {
90
+ const filteredPages = pages.filter(p => p.language_id === currentLangId);
91
+ setAvailablePages(filteredPages);
92
+
93
+ const filteredParentItems = parentItems.filter(p => p.language_id === currentLangId && p.id !== item?.id);
94
+ setAvailableParentItems(filteredParentItems);
95
+ } else {
96
+ setAvailablePages([]);
97
+ setAvailableParentItems([]);
98
+ }
99
+ }, [languageId, pages, parentItems, item?.id]);
100
+
101
+ const handlePageSelect = (selectedPageId: string) => {
102
+ setPageId(selectedPageId);
103
+ const selectedPage = availablePages.find(p => p.id.toString() === selectedPageId);
104
+ if (selectedPage) {
105
+ setUrl(`/${selectedPage.slug}`);
106
+ } else if (selectedPageId === "___NONE___") {
107
+ setUrl("#"); // Reset URL if "None" is selected, or keep previous manual URL
108
+ }
109
+ };
110
+
111
+ const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
112
+ event.preventDefault();
113
+ setFormMessage(null);
114
+ const formData = new FormData(event.currentTarget);
115
+
116
+ // Append translation group ID if creating a new translation from an existing group
117
+ if (fromTranslationGroupId && !isEditing) {
118
+ formData.append("from_translation_group_id", fromTranslationGroupId);
119
+ }
120
+ if (targetLanguageIdForTranslation && !isEditing) {
121
+ formData.append("target_language_id_for_translation", targetLanguageIdForTranslation);
122
+ }
123
+
124
+
125
+ startTransition(async () => {
126
+ const result = await formAction(formData);
127
+ if (result?.error) setFormMessage({ type: 'error', text: result.error });
128
+ });
129
+ };
130
+
131
+ if (authLoading || !isAdmin) {
132
+ return <div>Access Denied. Admin role required.</div>;
133
+ }
134
+ if (dataLoading && !item) return <div>Loading form data...</div>; // Show loading only if not editing an existing item with data
135
+
136
+ const menuLocations: MenuLocation[] = ['HEADER', 'FOOTER', 'SIDEBAR'];
137
+
138
+ return (
139
+ <form onSubmit={handleSubmit} className="space-y-6">
140
+ {formMessage && (
141
+ <div className={`p-3 rounded-md text-sm ${formMessage.type === 'success' ? 'bg-green-100 text-green-700 border border-green-200' : 'bg-red-100 text-red-700 border border-red-200'}`}>
142
+ {formMessage.text}
143
+ </div>
144
+ )}
145
+
146
+ {/* Hidden input for from_translation_group_id if present in URL params and not editing */}
147
+ {!isEditing && fromTranslationGroupId && (
148
+ <input type="hidden" name="from_translation_group_id" value={fromTranslationGroupId} />
149
+ )}
150
+ {/* Hidden input for target_language_id_for_translation if present and not editing */}
151
+ {!isEditing && targetLanguageIdForTranslation && (
152
+ <input type="hidden" name="target_language_id_for_translation" value={targetLanguageIdForTranslation} />
153
+ )}
154
+
155
+
156
+ <div>
157
+ <Label htmlFor="label">Label</Label>
158
+ <Input id="label" name="label" value={label} onChange={(e) => setLabel(e.target.value)} required className="mt-1" />
159
+ </div>
160
+ <div>
161
+ <Label htmlFor="page_id">Link to Internal Page (Optional)</Label>
162
+ <Select name="page_id" value={pageId} onValueChange={handlePageSelect} disabled={!languageId}>
163
+ <SelectTrigger className="mt-1"><SelectValue placeholder="None (Manual URL)" /></SelectTrigger>
164
+ <SelectContent>
165
+ <SelectItem value="___NONE___">None (Manual URL)</SelectItem>
166
+ {availablePages.map((p) => (
167
+ <SelectItem key={p.id} value={p.id.toString()}>{p.title} ({p.slug})</SelectItem>
168
+ ))}
169
+ </SelectContent>
170
+ </Select>
171
+ <p className="text-xs text-muted-foreground mt-1">Selecting a page will auto-fill the URL (can be overridden). Requires language to be selected first.</p>
172
+ </div>
173
+ <div>
174
+ <Label htmlFor="url">URL</Label>
175
+ <Input id="url" name="url" value={url} onChange={(e) => setUrl(e.target.value)} required className="mt-1" placeholder="/about-us or https://example.com" />
176
+ </div>
177
+ <div>
178
+ <Label htmlFor="language_id">Language</Label>
179
+ <Select
180
+ name="language_id"
181
+ value={languageId}
182
+ onValueChange={(val) => {
183
+ setLanguageId(val);
184
+ // Reset page and parent item selection if language changes, as they are language-specific
185
+ setPageId("");
186
+ setParentId("");
187
+ if (url.startsWith("/")) setUrl("#"); // Reset relative URL if it was page-linked
188
+ }}
189
+ required
190
+ disabled={isEditing && !!targetLanguageIdForTranslation} // Disable if creating a specific translation or editing
191
+ >
192
+ <SelectTrigger className="mt-1"><SelectValue placeholder="Select language" /></SelectTrigger>
193
+ <SelectContent>
194
+ {availableLanguages.map((lang) => (
195
+ <SelectItem key={lang.id} value={lang.id.toString()}>{lang.name} ({lang.code})</SelectItem>
196
+ ))}
197
+ </SelectContent>
198
+ </Select>
199
+ </div>
200
+ <div>
201
+ <Label htmlFor="menu_key">Menu Location</Label>
202
+ <Select
203
+ name="menu_key"
204
+ value={menuKey}
205
+ defaultValue="HEADER"
206
+ onValueChange={(val) => {
207
+ setMenuKey(val as MenuLocation);
208
+ setParentId(""); // Reset parent if menu key changes
209
+ }}
210
+ required
211
+ disabled={isEditing && !!initialMenuKeyFromParam} // Disable if creating translation for specific menu
212
+ >
213
+ <SelectTrigger className="mt-1"><SelectValue placeholder="Select menu location" /></SelectTrigger>
214
+ <SelectContent>
215
+ {menuLocations.map((loc) => (
216
+ <SelectItem key={loc} value={loc}>{loc.charAt(0) + loc.slice(1).toLowerCase()}</SelectItem>
217
+ ))}
218
+ </SelectContent>
219
+ </Select>
220
+ </div>
221
+ <div>
222
+ <Label htmlFor="order">Order</Label>
223
+ <Input id="order" name="order" type="number" value={order} onChange={(e) => setOrder(e.target.value)} required className="mt-1" />
224
+ </div>
225
+ <div>
226
+ <Label htmlFor="parent_id">Parent Item (Optional)</Label>
227
+ <Select name="parent_id" value={parentId} onValueChange={setParentId} disabled={!languageId || !menuKey}>
228
+ <SelectTrigger className="mt-1"><SelectValue placeholder="None (Top Level)" /></SelectTrigger>
229
+ <SelectContent>
230
+ <SelectItem value="___NONE___">None (Top Level)</SelectItem>
231
+ {availableParentItems
232
+ .filter(p => p.menu_key === menuKey)
233
+ .map((parent) => (
234
+ <SelectItem key={parent.id} value={parent.id.toString()}>{parent.label}</SelectItem>
235
+ ))}
236
+ </SelectContent>
237
+ </Select>
238
+ <p className="text-xs text-muted-foreground mt-1">Parents must be in the same language and menu location.</p>
239
+ </div>
240
+ <div className="flex justify-end space-x-3">
241
+ <Button type="button" variant="outline" onClick={() => router.push("/cms/navigation")} disabled={isPending}>Cancel</Button>
242
+ <Button type="submit" disabled={isPending || dataLoading || !languageId || !menuKey}>
243
+ {isPending ? "Saving..." : actionButtonText}
244
+ </Button>
245
+ </div>
246
+ </form>
247
+ );
248
+ }