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,577 @@
1
+ // app/cms/media/actions.ts
2
+ "use server";
3
+
4
+ import { createClient } from "@nextblock-cms/db/server";
5
+ import { revalidatePath } from "next/cache";
6
+ import type { Database } from "@nextblock-cms/db";
7
+ import { encodedRedirect } from "@nextblock-cms/utils/server";
8
+
9
+ type Media = Database['public']['Tables']['media']['Row'];
10
+
11
+ // --- recordMediaUpload and updateMediaItem functions to be updated similarly ---
12
+
13
+ // Define the structure for a single variant, mirroring what /api/process-image returns
14
+ interface ImageVariant {
15
+ objectKey: string;
16
+ url: string;
17
+ width: number;
18
+ height: number;
19
+ fileType: string;
20
+ sizeBytes: number;
21
+ variantLabel: string;
22
+ }
23
+
24
+ export async function recordMediaUpload(payload: {
25
+ fileName: string; // Original filename, can still be useful
26
+ // objectKey: string; // This might now be derived from the primary variant
27
+ // fileType: string; // This will come from the primary variant
28
+ // sizeBytes: number; // This will come from the primary variant
29
+ description?: string;
30
+ // width?: number; // This will come from the primary variant
31
+ // height?: number; // This will come from the primary variant
32
+ r2OriginalKey: string; // Key of the initially uploaded file in R2
33
+ r2Variants: ImageVariant[]; // Array of processed variants
34
+ originalImageDetails: ImageVariant; // Details of the original uploaded image from process-image
35
+ blurDataUrl?: string; // Optional, if generated and passed from client
36
+ }, returnJustData?: boolean): Promise<{ success: true; data: Media } | { error: string } | void> {
37
+ const supabase = createClient();
38
+ const { data: { user }, error: authError } = await supabase.auth.getUser();
39
+
40
+ if (authError || !user) {
41
+ if (returnJustData) return { error: "User not authenticated for media record." };
42
+ return encodedRedirect("error", "/cms/media", "User not authenticated for media record.");
43
+ }
44
+
45
+ const { data: profile } = await supabase
46
+ .from("profiles")
47
+ .select("role")
48
+ .eq("id", user.id)
49
+ .single();
50
+ if (!profile || !["ADMIN", "WRITER"].includes(profile.role)) {
51
+ if (returnJustData) return { error: "Forbidden: Insufficient permissions to record media." };
52
+ return encodedRedirect("error", "/cms/media", "Forbidden: Insufficient permissions to record media.");
53
+ }
54
+
55
+ // Determine the primary variant to use for the main table columns
56
+ // This logic can be adjusted. Prioritize 'original_avif', then 'xlarge_avif', then the first variant.
57
+ let primaryVariant =
58
+ payload.r2Variants.find(v => v.variantLabel === 'original_avif') ||
59
+ payload.r2Variants.find(v => v.variantLabel === 'xlarge_avif') ||
60
+ payload.r2Variants[0] || // Fallback to the first variant if specific ones aren't found
61
+ payload.originalImageDetails; // Fallback to original uploaded details if no variants
62
+
63
+ if (!primaryVariant) {
64
+ // This case should ideally not happen if originalImageDetails is always present
65
+ // but as a safeguard:
66
+ primaryVariant = payload.originalImageDetails || {
67
+ objectKey: payload.r2OriginalKey,
68
+ url: `YOUR_R2_PUBLIC_BASE_URL/${payload.r2OriginalKey}`, // Construct URL if needed
69
+ width: 0, // Or fetch if necessary, though client sends initial dimensions
70
+ height: 0,
71
+ fileType: 'application/octet-stream', // A generic fallback
72
+ sizeBytes: 0,
73
+ variantLabel: 'fallback_original'
74
+ };
75
+ }
76
+
77
+ // Construct the full list of variants to store, including the original uploaded file details
78
+ const allVariantsToStore = [
79
+ ...(payload.originalImageDetails && payload.originalImageDetails.objectKey !== primaryVariant.objectKey ? [payload.originalImageDetails] : []),
80
+ ...payload.r2Variants,
81
+ ].filter((variant, index, self) =>
82
+ index === self.findIndex((v) => v.objectKey === variant.objectKey)
83
+ ); // Ensure unique variants by objectKey
84
+
85
+ // If no description provided for images, derive one from the filename (un-slug)
86
+ const deriveAltFromFilename = (name: string) => {
87
+ const lastDot = name.lastIndexOf('.');
88
+ const base = lastDot > 0 ? name.substring(0, lastDot) : name;
89
+ const spaced = base.replace(/[-+_\\]+/g, ' ').replace(/\s+/g, ' ').trim();
90
+ return spaced.replace(/\b\w+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1));
91
+ };
92
+
93
+ const computedDescription = payload.description
94
+ ?? ((primaryVariant.fileType?.startsWith('image/') || payload.originalImageDetails?.fileType?.startsWith('image/'))
95
+ ? deriveAltFromFilename(payload.fileName)
96
+ : null);
97
+
98
+ const mediaData: Omit<Media, 'id' | 'created_at' | 'updated_at'> & { uploader_id: string } = {
99
+ uploader_id: user.id,
100
+ file_name: payload.fileName, // Keep original file name for reference
101
+ object_key: primaryVariant.objectKey, // Key of the primary display version
102
+ file_path: primaryVariant.objectKey,
103
+ folder: (() => {
104
+ const match = primaryVariant.objectKey.match(/^(.*\/)?.*$/);
105
+ const path = match && match[1] ? match[1] : null;
106
+ return path;
107
+ })(),
108
+ // file_url is removed as it's not in the Media type; URLs are in variants
109
+ file_type: primaryVariant.fileType,
110
+ size_bytes: primaryVariant.sizeBytes,
111
+ description: computedDescription,
112
+ width: primaryVariant.width,
113
+ height: primaryVariant.height,
114
+ variants: allVariantsToStore as any, // Store all variants including the original
115
+ blur_data_url: payload.blurDataUrl || null, // Store if provided
116
+ // Ensure all other required fields for 'Media' type are present or nullable
117
+ };
118
+
119
+ const { data: newMedia, error } = await supabase
120
+ .from("media")
121
+ .insert(mediaData)
122
+ .select()
123
+ .single();
124
+
125
+ if (error) {
126
+ console.error("Error recording media upload:", error);
127
+ if (returnJustData) return { error: `Failed to record media: ${error.message}` };
128
+ return encodedRedirect("error", "/cms/media", `Failed to record media: ${error.message}`);
129
+ }
130
+
131
+ revalidatePath("/cms/media");
132
+ if (returnJustData) {
133
+ return { success: true, data: newMedia as Media };
134
+ } else {
135
+ encodedRedirect("success", "/cms/media", "Media recorded successfully.");
136
+ }
137
+ }
138
+
139
+
140
+ export async function updateMediaItem(
141
+ mediaId: string,
142
+ payload: { description?: string; file_name?: string },
143
+ returnJustData?: boolean
144
+ ) {
145
+ const supabase = createClient();
146
+ const { data: { user } } = await supabase.auth.getUser();
147
+ const mediaEditPath = `/cms/media/${mediaId}/edit`;
148
+
149
+ if (!user) {
150
+ if (returnJustData) return { error: "User not authenticated for media update." };
151
+ return encodedRedirect("error", mediaEditPath, "User not authenticated for media update.");
152
+ }
153
+
154
+ const { data: profile } = await supabase.from("profiles").select("role").eq("id", user.id).single();
155
+ if (!profile || !["ADMIN", "WRITER"].includes(profile.role)) {
156
+ if (returnJustData) return { error: "Forbidden to update media." };
157
+ return encodedRedirect("error", mediaEditPath, "Forbidden to update media.");
158
+ }
159
+
160
+ const updateData: Partial<Pick<Media, 'description' | 'file_name' | 'updated_at'>> = {};
161
+ if (payload.description !== undefined) updateData.description = payload.description;
162
+ if (payload.file_name !== undefined) updateData.file_name = payload.file_name;
163
+
164
+ if (Object.keys(updateData).length === 0) {
165
+ if (returnJustData) return { error: "No updatable fields provided for media." };
166
+ return encodedRedirect("error", mediaEditPath, "No updatable fields provided for media.");
167
+ }
168
+ updateData.updated_at = new Date().toISOString();
169
+
170
+ const { data: updatedMedia, error } = await supabase
171
+ .from("media")
172
+ .update(updateData)
173
+ .eq("id", mediaId)
174
+ .select()
175
+ .single();
176
+
177
+ if (error) {
178
+ console.error("Error updating media item:", error);
179
+ if (returnJustData) return { error: `Error updating media: ${error.message}` };
180
+ return encodedRedirect("error", mediaEditPath, `Error updating media: ${error.message}`);
181
+ }
182
+ revalidatePath("/cms/media");
183
+ revalidatePath(mediaEditPath);
184
+ if (returnJustData) {
185
+ return { success: true, media: updatedMedia } as { success: true; media: Media };
186
+ }
187
+ encodedRedirect("success", mediaEditPath, "Media item updated successfully.");
188
+ }
189
+
190
+
191
+ export async function deleteMediaItem(mediaId: string, objectKey: string) {
192
+ const supabase = createClient();
193
+ const { data: { user } } = await supabase.auth.getUser();
194
+
195
+ if (!user) {
196
+ return encodedRedirect("error", "/cms/media", "User not authenticated.");
197
+ }
198
+
199
+ const { data: profile } = await supabase.from("profiles").select("role").eq("id", user.id).single();
200
+ if (!profile || !["ADMIN", "WRITER"].includes(profile.role)) {
201
+ return encodedRedirect("error", "/cms/media", "Forbidden: Insufficient permissions.");
202
+ }
203
+
204
+ const { DeleteObjectCommand } = await import("@aws-sdk/client-s3");
205
+ const { getS3Client } = await import("@nextblock-cms/utils/server");
206
+ const s3Client = await getS3Client();
207
+ const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME;
208
+
209
+ if (!R2_BUCKET_NAME) {
210
+ return encodedRedirect("error", "/cms/media", "R2 Bucket not configured for deletion.");
211
+ }
212
+ if (!s3Client) {
213
+ return encodedRedirect("error", "/cms/media", "R2 client is not configured for deletion.");
214
+ }
215
+
216
+ try {
217
+ const deleteCommand = new DeleteObjectCommand({
218
+ Bucket: R2_BUCKET_NAME,
219
+ Key: objectKey,
220
+ });
221
+ await s3Client.send(deleteCommand);
222
+ } catch (r2Error: unknown) {
223
+ console.error("Error deleting from R2:", r2Error);
224
+ // Decide if you want to proceed with DB deletion if R2 deletion fails
225
+ // It's often better to proceed and log, or handle more gracefully.
226
+ // For now, we'll let it proceed to DB deletion but the error is logged.
227
+ // You could redirect with a partial success/warning message here.
228
+ }
229
+
230
+ const { error: dbError } = await supabase.from("media").delete().eq("id", mediaId);
231
+
232
+ if (dbError) {
233
+ console.error("Error deleting media record from DB:", dbError);
234
+ return encodedRedirect("error", "/cms/media", `Failed to delete media record: ${dbError.message}`);
235
+ }
236
+
237
+ revalidatePath("/cms/media");
238
+ encodedRedirect("success", "/cms/media", "Media item deleted successfully.");
239
+ }
240
+
241
+ export async function deleteMultipleMediaItems(items: Array<{ id: string; objectKey: string }>) {
242
+ const supabase = createClient();
243
+ const { data: { user }, error: authError } = await supabase.auth.getUser();
244
+
245
+ if (authError || !user) {
246
+ return { error: "User not authenticated." };
247
+ }
248
+
249
+ const { data: profile } = await supabase.from("profiles").select("role").eq("id", user.id).single();
250
+ if (!profile || !["ADMIN", "WRITER"].includes(profile.role)) {
251
+ return { error: "Forbidden: Insufficient permissions." };
252
+ }
253
+
254
+ if (!items || items.length === 0) {
255
+ return { error: "No items selected for deletion." };
256
+ }
257
+
258
+ const { DeleteObjectsCommand } = await import("@aws-sdk/client-s3"); // Use DeleteObjects for batch
259
+ const { getS3Client } = await import("@nextblock-cms/utils/server");
260
+ const s3Client = await getS3Client();
261
+ const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME;
262
+
263
+ if (!R2_BUCKET_NAME) {
264
+ return { error: "R2 Bucket not configured for deletion." };
265
+ }
266
+ if (!s3Client) {
267
+ return { error: "R2 client is not configured for deletion." };
268
+ }
269
+
270
+ const r2ObjectsToDelete = items.map(item => ({ Key: item.objectKey }));
271
+ const itemIdsToDelete = items.map(item => item.id);
272
+ let r2DeletionError = null;
273
+ let dbDeletionError = null;
274
+
275
+ // Batch delete from R2
276
+ try {
277
+ if (r2ObjectsToDelete.length > 0) {
278
+ const deleteCommand = new DeleteObjectsCommand({
279
+ Bucket: R2_BUCKET_NAME,
280
+ Delete: { Objects: r2ObjectsToDelete },
281
+ });
282
+ const output = await s3Client.send(deleteCommand);
283
+ if (output.Errors && output.Errors.length > 0) {
284
+ console.error("Errors deleting some objects from R2:", output.Errors);
285
+ // Collect specific errors if needed, for now a general message
286
+ r2DeletionError = `Some objects failed to delete from R2: ${output.Errors.map(e => e.Key).join(', ')}`;
287
+ }
288
+ }
289
+ } catch (error: unknown) {
290
+ console.error("Error batch deleting from R2:", error);
291
+ r2DeletionError = `Failed to delete objects from R2: ${error instanceof Error ? error.message : String(error)}`;
292
+ }
293
+
294
+ // Batch delete from Supabase
295
+ try {
296
+ if (itemIdsToDelete.length > 0) {
297
+ const { error } = await supabase.from("media").delete().in("id", itemIdsToDelete);
298
+ if (error) {
299
+ throw error;
300
+ }
301
+ }
302
+ } catch (error: unknown) {
303
+ console.error("Error batch deleting media records from DB:", error);
304
+ dbDeletionError = `Failed to delete media records from DB: ${error instanceof Error ? error.message : String(error)}`;
305
+ }
306
+
307
+ if (r2DeletionError || dbDeletionError) {
308
+ // Construct a combined error message
309
+ const errors = [r2DeletionError, dbDeletionError].filter(Boolean).join(" | ");
310
+ // No redirect here, return error object for client-side handling
311
+ return { error: `Deletion process encountered issues: ${errors}` };
312
+ }
313
+
314
+ revalidatePath("/cms/media");
315
+ // No redirect here, return success object for client-side handling
316
+ return { success: "Selected media items deleted successfully." };
317
+ }
318
+
319
+
320
+ export async function moveMultipleMediaItems(
321
+ items: Array<{ id: string; objectKey: string }>,
322
+ destinationFolder: string
323
+ ) {
324
+ const supabase = createClient();
325
+ const { data: { user }, error: authError } = await supabase.auth.getUser();
326
+
327
+ if (authError || !user) {
328
+ return { error: "User not authenticated." };
329
+ }
330
+
331
+ const { data: profile } = await supabase.from("profiles").select("role").eq("id", user.id).single();
332
+ if (!profile || !["ADMIN", "WRITER"].includes(profile.role)) {
333
+ return { error: "Forbidden: Insufficient permissions." };
334
+ }
335
+
336
+ const sanitizeFolder = (input?: string | null) => {
337
+ const f = (input ?? '').toString().trim();
338
+ if (!f) return 'uploads/';
339
+ let cleaned = f.replace(/^\/+/, '');
340
+ cleaned = cleaned.replace(/\\/g, '/');
341
+ cleaned = cleaned.replace(/\.{2,}/g, '');
342
+ cleaned = cleaned.replace(/[^a-zA-Z0-9_\-/]+/g, '-');
343
+ if (cleaned && !cleaned.endsWith('/')) cleaned += '/';
344
+ return cleaned || 'uploads/';
345
+ };
346
+ const folder = sanitizeFolder(destinationFolder);
347
+
348
+ const { CopyObjectCommand, DeleteObjectCommand, ListObjectsV2Command, HeadObjectCommand } = await import("@aws-sdk/client-s3");
349
+ const { getS3Client } = await import("@nextblock-cms/utils/server");
350
+ const s3Client = await getS3Client();
351
+ const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME;
352
+ const R2_PUBLIC_URL_BASE = process.env.NEXT_PUBLIC_R2_BASE_URL || '';
353
+
354
+ if (!R2_BUCKET_NAME) {
355
+ return { error: "R2 Bucket not configured for move." };
356
+ }
357
+ if (!s3Client) {
358
+ return { error: "R2 client is not configured for move." };
359
+ }
360
+
361
+ if (!items || items.length === 0) {
362
+ return { error: "No items selected for move." };
363
+ }
364
+
365
+ const results: Array<{ id: string; ok: boolean; error?: string }> = [];
366
+
367
+ for (const item of items) {
368
+ try {
369
+ // Load media to retrieve variants
370
+ const { data: mediaRow, error: mediaError } = await supabase
371
+ .from("media")
372
+ .select("id, object_key, file_type, variants")
373
+ .eq("id", item.id)
374
+ .single();
375
+
376
+ if (mediaError || !mediaRow) {
377
+ results.push({ id: item.id, ok: false, error: mediaError?.message || 'Media not found' });
378
+ continue;
379
+ }
380
+
381
+ const getFilename = (key: string) => key.substring(key.lastIndexOf('/') + 1);
382
+ let newMainKey = `${folder}${getFilename(mediaRow.object_key)}`;
383
+
384
+ // Build list of keys to move: primary + variant keys (if any)
385
+ type Variant = { objectKey: string; url?: string; [k: string]: any };
386
+ const oldVariants: Variant[] = Array.isArray(mediaRow.variants) ? (mediaRow.variants as Variant[]) :
387
+ (typeof mediaRow.variants === 'object' && mediaRow.variants !== null ? (mediaRow.variants as any) : []);
388
+
389
+ const variantMoves = (oldVariants || []).map((v) => ({
390
+ oldKey: v.objectKey,
391
+ newKey: `${folder}${getFilename(v.objectKey)}`,
392
+ }));
393
+
394
+ const objectsToMove = [
395
+ { oldKey: mediaRow.object_key, newKey: newMainKey, isMain: true },
396
+ ...variantMoves.map(v => ({ ...v, isMain: false })),
397
+ ];
398
+
399
+ // Copy then delete each object; tolerate missing variant keys, but main object must exist
400
+ const movedKeys = new Set<string>();
401
+ let mainMoved = false;
402
+ for (const { oldKey, newKey, isMain } of objectsToMove as Array<{oldKey:string; newKey:string; isMain:boolean}>) {
403
+ if (oldKey === newKey) continue;
404
+ // If destination already has the object, treat as moved and try to delete source if present
405
+ let destinationExists = false;
406
+ try {
407
+ await s3Client.send(new HeadObjectCommand({ Bucket: R2_BUCKET_NAME, Key: newKey }));
408
+ destinationExists = true;
409
+ } catch {
410
+ destinationExists = false;
411
+ }
412
+
413
+ if (destinationExists) {
414
+ movedKeys.add(oldKey);
415
+ if (isMain) {
416
+ mainMoved = true;
417
+ newMainKey = newKey;
418
+ }
419
+ await s3Client
420
+ .send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: oldKey }))
421
+ .catch(() => undefined);
422
+ continue;
423
+ }
424
+
425
+ const encodedSourceKey = encodeURIComponent(oldKey).replace(/%2F/g, '/');
426
+ const copySource = `/${R2_BUCKET_NAME}/${encodedSourceKey}`; // S3/R2 expects a leading slash
427
+ try {
428
+ await s3Client.send(new CopyObjectCommand({ Bucket: R2_BUCKET_NAME, CopySource: copySource, Key: newKey }));
429
+ await s3Client.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: oldKey }));
430
+ movedKeys.add(oldKey);
431
+ if (isMain) mainMoved = true;
432
+ } catch (err: any) {
433
+ const name = err?.name || '';
434
+ const message = err?.message || String(err);
435
+ if (isMain) {
436
+ // Main object missing: attempt fallback to any existing variant
437
+ let promoted = false;
438
+ for (const vm of variantMoves) {
439
+ try {
440
+ const encVar = encodeURIComponent(vm.oldKey).replace(/%2F/g, '/');
441
+ const srcVar = `/${R2_BUCKET_NAME}/${encVar}`;
442
+ await s3Client.send(new CopyObjectCommand({ Bucket: R2_BUCKET_NAME, CopySource: srcVar, Key: vm.newKey }));
443
+ await s3Client.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: vm.oldKey }));
444
+ movedKeys.add(vm.oldKey);
445
+ newMainKey = vm.newKey; // promote this variant as new main
446
+ mainMoved = true;
447
+ promoted = true;
448
+ break;
449
+ } catch {
450
+ continue; // try next variant
451
+ }
452
+ }
453
+ if (!promoted) {
454
+ // Last-resort fallback: list objects using a base prefix derived from timestamped key
455
+ // Keys look like: uploads/name_YYYYMMDDHHMMSS_original.avif or uploads/name_YYYYMMDD.png
456
+ const withoutExt = mediaRow.object_key.replace(/\.[^/.]+$/, '');
457
+ const tsMatch = withoutExt.match(/^(.*?_\d{8,14})/); // capture up to timestamp
458
+ const basePrefix = tsMatch ? tsMatch[1] : withoutExt.replace(/_(original(?:_uploaded)?|xlarge_avif|large_avif|medium_avif|small_avif|thumbnail_avif|[a-z]+)$/i, '');
459
+ // Ensure it ends with the underscore-delimited base, not variant label
460
+ const prefixGuess = basePrefix;
461
+ try {
462
+ const listed = await s3Client.send(new ListObjectsV2Command({ Bucket: R2_BUCKET_NAME, Prefix: prefixGuess }));
463
+ const keys = (listed.Contents || []).map(o => o.Key).filter(Boolean) as string[];
464
+ // Prefer common image extensions if present
465
+ const preferred = keys.find(k => /\.(avif|png|jpe?g|webp|gif|svg)$/i.test(k)) || keys[0];
466
+ if (preferred) {
467
+ const enc = encodeURIComponent(preferred).replace(/%2F/g, '/');
468
+ const src = `/${R2_BUCKET_NAME}/${enc}`;
469
+ const targetKey = `${folder}${getFilename(preferred)}`;
470
+ await s3Client.send(new CopyObjectCommand({ Bucket: R2_BUCKET_NAME, CopySource: src, Key: targetKey }));
471
+ await s3Client.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: preferred }));
472
+ movedKeys.add(preferred);
473
+ newMainKey = targetKey;
474
+ mainMoved = true;
475
+ } else {
476
+ results.push({ id: item.id, ok: false, error: `Main key missing: ${oldKey} (${name}: ${message})` });
477
+ mainMoved = false;
478
+ }
479
+ } catch {
480
+ results.push({ id: item.id, ok: false, error: `Main key missing: ${oldKey} (${name}: ${message})` });
481
+ mainMoved = false;
482
+ }
483
+ }
484
+ // Regardless, skip to next objectToMove item
485
+ continue;
486
+ }
487
+ // Variant missing: tolerate and continue; we'll drop it from variants below
488
+ // Do nothing (optionally could log)
489
+ }
490
+ }
491
+ if (!mainMoved) {
492
+ // Already pushed a result; proceed to next item
493
+ continue;
494
+ }
495
+
496
+ // Update variants with new keys + urls
497
+ const newVariants = (oldVariants || [])
498
+ .filter((v) => movedKeys.has(v.objectKey)) // keep only variants that were successfully moved
499
+ .map((v) => {
500
+ const filename = getFilename(v.objectKey);
501
+ const updatedKey = `${folder}${filename}`;
502
+ const updated = { ...v, objectKey: updatedKey } as any;
503
+ if (R2_PUBLIC_URL_BASE) updated.url = `${R2_PUBLIC_URL_BASE}/${updatedKey}`;
504
+ return updated;
505
+ });
506
+
507
+ // Update DB
508
+ const { error: updateError } = await supabase
509
+ .from('media')
510
+ .update({ object_key: newMainKey, file_path: newMainKey, folder, variants: newVariants as any })
511
+ .eq('id', item.id);
512
+ if (updateError) {
513
+ results.push({ id: item.id, ok: false, error: updateError.message });
514
+ continue;
515
+ }
516
+
517
+ results.push({ id: item.id, ok: true });
518
+ } catch (err: any) {
519
+ const msg = err?.name && err?.message ? `${err.name}: ${err.message}` : (err?.message || String(err));
520
+ results.push({ id: item.id, ok: false, error: msg });
521
+ }
522
+ }
523
+
524
+ const failed = results.filter(r => !r.ok);
525
+ if (failed.length > 0) {
526
+ const detail = failed.map(f => `${f.id}${f.error ? ` (${f.error})` : ''}`).join(', ');
527
+ return { error: `Moved ${results.length - failed.length} item(s), ${failed.length} failed: ${detail}` };
528
+ }
529
+
530
+ revalidatePath('/cms/media');
531
+ return { success: `Moved ${results.length} item(s) to ${folder}` };
532
+ }
533
+
534
+ export async function moveSingleMediaItem(item: { id: string; objectKey: string }, destinationFolder: string) {
535
+ // Reuse the multiple-items logic for a single element to keep behavior aligned.
536
+ const res = await moveMultipleMediaItems([item], destinationFolder);
537
+ return res;
538
+ }
539
+
540
+
541
+ // Type for inserting media
542
+
543
+ export async function getMediaItems(
544
+ page = 1,
545
+ limit = 50 // Default to 50 items per page
546
+ ): Promise<{ data?: Media[]; error?: string; hasMore?: boolean }> {
547
+ const supabase = createClient();
548
+ const { data: { user }, error: authError } = await supabase.auth.getUser();
549
+
550
+ if (authError || !user) {
551
+ return { error: "User not authenticated." };
552
+ }
553
+
554
+ // Optional: Check user role if only certain roles can view all media
555
+ // const { data: profile } = await supabase.from("profiles").select("role").eq("id", user.id).single();
556
+ // if (!profile || !["ADMIN", "WRITER"].includes(profile.role)) {
557
+ // return { error: "Forbidden: Insufficient permissions." };
558
+ // }
559
+
560
+ const from = (page - 1) * limit;
561
+ const to = from + limit - 1;
562
+
563
+ const { data, error, count } = await supabase
564
+ .from("media")
565
+ .select("*", { count: "exact" })
566
+ .order("created_at", { ascending: false })
567
+ .range(from, to);
568
+
569
+ if (error) {
570
+ console.error("Error fetching media items:", error);
571
+ return { error: `Failed to fetch media items: ${error.message}` };
572
+ }
573
+
574
+ const hasMore = count ? to < count -1 : false;
575
+
576
+ return { data: data as Media[], hasMore };
577
+ }
@@ -0,0 +1,53 @@
1
+ // app/cms/media/components/DeleteMediaButtonClient.tsx
2
+ "use client";
3
+
4
+ import React, { useState, useTransition } from 'react';
5
+ import { DropdownMenuItem } from "@nextblock-cms/ui";
6
+ import { Trash2 } from "lucide-react";
7
+ import type { Database } from "@nextblock-cms/db";
8
+ import { deleteMediaItem } from "../actions";
9
+ import { ConfirmationModal } from '@/app/cms/components/ConfirmationModal';
10
+
11
+ type Media = Database['public']['Tables']['media']['Row'];
12
+
13
+ interface DeleteMediaButtonClientProps {
14
+ mediaItem: Media;
15
+ }
16
+
17
+ export default function DeleteMediaButtonClient({ mediaItem }: DeleteMediaButtonClientProps) {
18
+ const [isModalOpen, setIsModalOpen] = useState(false);
19
+ const [isPending, startTransition] = useTransition();
20
+
21
+ const handleDelete = () => {
22
+ startTransition(async () => {
23
+ await deleteMediaItem(mediaItem.id, mediaItem.object_key);
24
+ setIsModalOpen(false);
25
+ });
26
+ };
27
+
28
+ const handleSelect = (event: Event) => {
29
+ event.preventDefault();
30
+ setIsModalOpen(true);
31
+ };
32
+
33
+ return (
34
+ <>
35
+ <DropdownMenuItem
36
+ className="text-red-600 hover:!text-red-600 hover:!bg-red-50 dark:hover:!bg-red-700/20 cursor-pointer"
37
+ onSelect={handleSelect}
38
+ disabled={isPending}
39
+ >
40
+ <Trash2 className="mr-2 h-4 w-4" />
41
+ {isPending ? 'Deleting...' : 'Delete'}
42
+ </DropdownMenuItem>
43
+ <ConfirmationModal
44
+ isOpen={isModalOpen}
45
+ onClose={() => setIsModalOpen(false)}
46
+ onConfirm={handleDelete}
47
+ title="Are you sure?"
48
+ description="This will permanently delete the media file. This action cannot be undone."
49
+ confirmText={isPending ? 'Deleting...' : 'Delete'}
50
+ />
51
+ </>
52
+ );
53
+ }