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,177 @@
1
+ 'use client'
2
+
3
+ import { useState, useTransition, type ChangeEvent } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import Image from 'next/image'
6
+ import { Input } from '@nextblock-cms/ui'
7
+ import { Label } from '@nextblock-cms/ui'
8
+ import { Button } from '@nextblock-cms/ui'
9
+ import type { Database } from '@nextblock-cms/db'
10
+ import { ImageIcon, X as XIcon } from 'lucide-react'
11
+ import MediaPickerDialog from '@/app/cms/media/components/MediaPickerDialog'
12
+ type Media = Database['public']['Tables']['media']['Row'];
13
+ const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || ''
14
+
15
+ interface LogoDetails {
16
+ id?: string
17
+ name: string
18
+ media_id: string | null
19
+ object_key: string | null
20
+ width: number | null
21
+ height: number | null
22
+ blur_data_url: string | null
23
+ }
24
+
25
+ interface LogoFormProps { logo?: Database["public"]["Tables"]["logos"]["Row"] & { media: Media | null }
26
+ action: (
27
+ payload:
28
+ | { name: string; media_id: string }
29
+ | { id: string; name: string; media_id: string },
30
+ ) => Promise<{ success: boolean; error?: string }>
31
+ }
32
+
33
+ export default function LogoForm({ logo, action }: LogoFormProps) {
34
+ const router = useRouter()
35
+ const [logoDetails, setLogoDetails] = useState<LogoDetails>({
36
+ id: logo?.id,
37
+ name: logo?.name || '',
38
+ media_id: logo?.media_id || null,
39
+ object_key: logo?.media?.object_key || null,
40
+ width: logo?.media?.width || null,
41
+ height: logo?.media?.height || null,
42
+ blur_data_url: logo?.media?.blur_data_url || null,
43
+ })
44
+ const [formError, setFormError] = useState<string | null>(null)
45
+ const [isPending, startTransition] = useTransition()
46
+
47
+ // Removed unused media library state and effect
48
+
49
+ const handleMediaSelect = (media: Media) => {
50
+ setLogoDetails(prev => ({
51
+ ...prev,
52
+ media_id: media.id,
53
+ object_key: media.object_key,
54
+ width: media.width ?? null,
55
+ height: media.height ?? null,
56
+ blur_data_url: media.blur_data_url ?? null,
57
+ }))
58
+ // MediaPickerDialog closes itself after selection
59
+ }
60
+
61
+ const handleRemoveImage = () => {
62
+ setLogoDetails(prev => ({
63
+ ...prev,
64
+ media_id: null,
65
+ object_key: null,
66
+ width: null,
67
+ height: null,
68
+ blur_data_url: null,
69
+ }))
70
+ }
71
+
72
+ const handleSave = async () => {
73
+ if (!logoDetails.name || !logoDetails.media_id) {
74
+ setFormError('Please provide a name and select an image.')
75
+ return
76
+ }
77
+
78
+ setFormError(null)
79
+
80
+ startTransition(async () => {
81
+ const payload = {
82
+ name: logoDetails.name,
83
+ media_id: logoDetails.media_id as string,
84
+ ...(logoDetails.id && { id: logoDetails.id }),
85
+ }
86
+ const result = await action(payload)
87
+
88
+ if (result?.error) {
89
+ setFormError(result.error)
90
+ }
91
+
92
+ if (result?.success) {
93
+ router.push('/cms/settings/logos')
94
+ router.refresh()
95
+ }
96
+ })
97
+ }
98
+
99
+ return (
100
+ <div className="space-y-6">
101
+ <div>
102
+ <Label htmlFor="name">Logo Name</Label>
103
+ <Input
104
+ id="name"
105
+ name="name"
106
+ value={logoDetails.name}
107
+ onChange={(e: ChangeEvent<HTMLInputElement>) =>
108
+ setLogoDetails(prev => ({ ...prev, name: e.target.value }))
109
+ }
110
+ required
111
+ className="mt-1"
112
+ />
113
+ </div>
114
+
115
+ <div>
116
+ <Label>Logo Image</Label>
117
+ <div className="mt-1 p-3 border rounded-md bg-muted/30 min-h-[120px] flex flex-col items-center justify-center">
118
+ {logoDetails.object_key &&
119
+ logoDetails.width &&
120
+ logoDetails.height ? (
121
+ <div
122
+ className="relative group inline-block"
123
+ style={{ maxWidth: logoDetails.width, maxHeight: 200 }}
124
+ >
125
+ <Image
126
+ src={`${R2_BASE_URL}/${logoDetails.object_key}`}
127
+ alt={logoDetails.name || 'Selected logo'}
128
+ width={logoDetails.width}
129
+ height={logoDetails.height}
130
+ className="rounded-md object-contain"
131
+ style={{ maxHeight: '200px' }}
132
+ placeholder={logoDetails.blur_data_url ? 'blur' : 'empty'}
133
+ blurDataURL={logoDetails.blur_data_url || undefined}
134
+ />
135
+ <Button
136
+ type="button"
137
+ variant="destructive"
138
+ size="icon"
139
+ className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6"
140
+ onClick={handleRemoveImage}
141
+ title="Remove Image"
142
+ >
143
+ <XIcon className="h-3 w-3" />
144
+ </Button>
145
+ </div>
146
+ ) : (
147
+ <ImageIcon className="h-16 w-16 text-muted-foreground" />
148
+ )}
149
+
150
+ <MediaPickerDialog
151
+ triggerLabel={logoDetails.object_key ? 'Change Image' : 'Select from Library'}
152
+ onSelect={handleMediaSelect}
153
+ accept={(m: Media) => !!m.file_type?.startsWith('image/')}
154
+ title="Select or Upload Logo"
155
+ defaultFolder="logos/"
156
+ />
157
+ </div>
158
+ </div>
159
+
160
+ <div className="flex items-center gap-4">
161
+ <Button
162
+ onClick={handleSave}
163
+ disabled={isPending || !logoDetails.name || !logoDetails.media_id}
164
+ >
165
+ {isPending ? 'Saving...' : `${logo ? 'Update' : 'Create'} Logo`}
166
+ </Button>
167
+ {formError && (
168
+ <div className="text-red-500 text-sm">{formError}</div>
169
+ )}
170
+ </div>
171
+ </div>
172
+ )
173
+ }
174
+
175
+
176
+
177
+
@@ -0,0 +1,11 @@
1
+ import LogoForm from '../components/LogoForm'
2
+ import { createLogo } from '../actions'
3
+
4
+ export default function NewLogoPage() {
5
+ return (
6
+ <div>
7
+ <h1 className="text-2xl font-semibold mb-6">Create New Logo</h1>
8
+ <LogoForm action={createLogo} />
9
+ </div>
10
+ )
11
+ }
@@ -0,0 +1,118 @@
1
+ import React from 'react'
2
+ import Link from 'next/link'
3
+ import { Button } from '@nextblock-cms/ui'
4
+ import {
5
+ Table,
6
+ TableBody,
7
+ TableCell,
8
+ TableHead,
9
+ TableHeader,
10
+ TableRow,
11
+ } from '@nextblock-cms/ui'
12
+ import { MoreHorizontal, PlusCircle, Edit3, Image as ImageIcon } from 'lucide-react'
13
+ import {
14
+ DropdownMenu,
15
+ DropdownMenuContent,
16
+ DropdownMenuItem,
17
+ DropdownMenuTrigger,
18
+ DropdownMenuSeparator,
19
+ } from '@nextblock-cms/ui'
20
+ import { deleteLogo, getLogos } from './actions'
21
+ import MediaImage from '@/app/cms/media/components/MediaImage'
22
+
23
+ const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || ''
24
+
25
+ export default async function CmsLogosListPage() {
26
+ const logos = await getLogos()
27
+
28
+ return (
29
+ <div className="w-full">
30
+ <div className="flex justify-between items-center mb-6">
31
+ <h1 className="text-2xl font-semibold">Manage Logos</h1>
32
+ <Button variant="default" asChild>
33
+ <Link href="/cms/settings/logos/new">
34
+ <PlusCircle className="mr-2 h-4 w-4" /> New Logo
35
+ </Link>
36
+ </Button>
37
+ </div>
38
+
39
+ {logos.length === 0 ? (
40
+ <div className="text-center py-10 border rounded-lg">
41
+ <ImageIcon className="mx-auto h-12 w-12 text-muted-foreground" />
42
+ <h3 className="mt-2 text-sm font-medium">No logos found.</h3>
43
+ <p className="mt-1 text-sm text-muted-foreground">
44
+ Get started by creating a new logo.
45
+ </p>
46
+ <div className="mt-6">
47
+ <Button asChild>
48
+ <Link href="/cms/settings/logos/new">
49
+ <PlusCircle className="mr-2 h-4 w-4" /> Create Logo
50
+ </Link>
51
+ </Button>
52
+ </div>
53
+ </div>
54
+ ) : (
55
+ <div className="rounded-lg border overflow-hidden">
56
+ <Table>
57
+ <TableHeader>
58
+ <TableRow>
59
+ <TableHead className="w-[80px]">Image</TableHead>
60
+ <TableHead>Name</TableHead>
61
+ <TableHead className="hidden md:table-cell">Created At</TableHead>
62
+ <TableHead className="text-right">Actions</TableHead>
63
+ </TableRow>
64
+ </TableHeader>
65
+ <TableBody>
66
+ {logos.map(logo => (
67
+ <TableRow key={logo.id}>
68
+ <TableCell>
69
+ {logo.media ? (
70
+ <MediaImage
71
+ src={`${R2_BASE_URL}/${logo.media.object_key}`}
72
+ alt={logo.media.alt_text || logo.name}
73
+ width={logo.media.width || 100}
74
+ height={logo.media.height || 100}
75
+ className="max-w-16 max-h-16 object-contain"
76
+ />
77
+ ) : (
78
+ <div className="w-16 h-16 bg-muted rounded-sm flex items-center justify-center">
79
+ <ImageIcon className="h-6 w-6 text-muted-foreground" />
80
+ </div>
81
+ )}
82
+ </TableCell>
83
+ <TableCell className="font-medium">{logo.name}</TableCell>
84
+ <TableCell className="hidden md:table-cell">
85
+ {new Date(logo.created_at).toLocaleDateString()}
86
+ </TableCell>
87
+ <TableCell className="text-right">
88
+ <DropdownMenu>
89
+ <DropdownMenuTrigger asChild>
90
+ <Button variant="ghost" size="icon">
91
+ <MoreHorizontal className="h-4 w-4" />
92
+ </Button>
93
+ </DropdownMenuTrigger>
94
+ <DropdownMenuContent align="end">
95
+ <DropdownMenuItem asChild>
96
+ <Link href={`/cms/settings/logos/${logo.id}/edit`}>
97
+ <Edit3 className="mr-2 h-4 w-4" />
98
+ Edit
99
+ </Link>
100
+ </DropdownMenuItem>
101
+ <DropdownMenuSeparator />
102
+ <form action={deleteLogo.bind(null, logo.id)}>
103
+ <button type="submit" className="w-full text-left px-2 py-1.5 text-sm text-red-500">
104
+ Delete
105
+ </button>
106
+ </form>
107
+ </DropdownMenuContent>
108
+ </DropdownMenu>
109
+ </TableCell>
110
+ </TableRow>
111
+ ))}
112
+ </TableBody>
113
+ </Table>
114
+ </div>
115
+ )}
116
+ </div>
117
+ )
118
+ }
@@ -0,0 +1,8 @@
1
+ import type { Database } from '@nextblock-cms/db';
2
+
3
+ export type Logo = Database['public']['Tables']['logos']['Row'] & {
4
+ site_title?: string | null;
5
+ media: (Database['public']['Tables']['media']['Row'] & {
6
+ alt_text: string | null;
7
+ }) | null;
8
+ };
@@ -0,0 +1,91 @@
1
+ // app/cms/users/[id]/edit/page.tsx
2
+ import { createClient } from "@nextblock-cms/db/server";
3
+ import UserForm from "../../components/UserForm";
4
+ import { updateUserProfile } from "../../actions";
5
+ import type { Database } from "@nextblock-cms/db";
6
+ import { notFound } from "next/navigation";
7
+
8
+ type Profile = Database['public']['Tables']['profiles']['Row'];
9
+ type AuthUser = {
10
+ id: string;
11
+ email: string | undefined;
12
+ created_at: string | undefined;
13
+ last_sign_in_at: string | undefined;
14
+ };
15
+
16
+ async function getUserAndProfileData(userId: string): Promise<{ authUser: AuthUser; profile: Profile | null } | null> {
17
+ const supabase = createClient();
18
+
19
+ // Fetch user from auth.users
20
+ // For admin operations, you might need a service_role client to fetch any user.
21
+ // However, for just getting user details by ID, this might work if RLS allows admin to read.
22
+ // A more robust way for admin panel is to use supabase.auth.admin.getUserById(userId)
23
+ // This requires a client initialized with SERVICE_ROLE_KEY.
24
+
25
+ // Let's use the admin API for fetching the auth user.
26
+ const { createClient: createServiceRoleClient } = await import('@supabase/supabase-js');
27
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
28
+ const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
29
+
30
+ if (!supabaseUrl || !serviceRoleKey) {
31
+ throw new Error('Missing required environment variables');
32
+ }
33
+
34
+ const serviceSupabase = createServiceRoleClient(supabaseUrl, serviceRoleKey);
35
+
36
+ const { data: { user: authUserData }, error: authUserError } = await serviceSupabase.auth.admin.getUserById(userId);
37
+
38
+ if (authUserError || !authUserData) {
39
+ console.error("Error fetching auth user for edit:", authUserError);
40
+ return null;
41
+ }
42
+
43
+ // Fetch profile from public.profiles
44
+ const { data: profileData, error: profileError } = await supabase
45
+ .from("profiles")
46
+ .select("*")
47
+ .eq("id", userId)
48
+ .single();
49
+
50
+ if (profileError && profileError.code !== 'PGRST116') { // PGRST116: single row not found, which is okay if profile not created yet
51
+ console.error("Error fetching profile for edit:", profileError);
52
+ // Decide if this is a critical error. A user might exist in auth but not profiles if trigger failed.
53
+ }
54
+
55
+ const simplifiedAuthUser: AuthUser = {
56
+ id: authUserData.id,
57
+ email: authUserData.email,
58
+ created_at: authUserData.created_at,
59
+ last_sign_in_at: authUserData.last_sign_in_at,
60
+ };
61
+
62
+ return { authUser: simplifiedAuthUser, profile: profileData as Profile | null };
63
+ }
64
+
65
+ export default async function EditUserPage(props: { params: Promise<{ id: string }> }) {
66
+ const params = await props.params;
67
+ const userId = params.id;
68
+ if (!userId) {
69
+ return notFound();
70
+ }
71
+
72
+ const userData = await getUserAndProfileData(userId);
73
+
74
+ if (!userData || !userData.authUser) {
75
+ return notFound(); // Or a more specific "User not found" component
76
+ }
77
+
78
+ const updateUserActionWithId = updateUserProfile.bind(null, userId);
79
+
80
+ return (
81
+ <div className="max-w-xl mx-auto">
82
+ <h1 className="text-2xl font-bold mb-6">Edit User: {userData.authUser.email}</h1>
83
+ <UserForm
84
+ userToEditAuth={userData.authUser}
85
+ userToEditProfile={userData.profile}
86
+ formAction={updateUserActionWithId}
87
+ actionButtonText="Update User Profile"
88
+ />
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,156 @@
1
+ // app/cms/users/actions.ts
2
+ "use server";
3
+
4
+ import { createClient } from "@nextblock-cms/db/server";
5
+ import { revalidatePath } from "next/cache";
6
+ import { redirect } from "next/navigation";
7
+ import type { Database } from "@nextblock-cms/db";
8
+
9
+ type UserRole = Database['public']['Enums']['user_role'];
10
+
11
+ // Helper to check admin role using the server client
12
+ async function verifyAdmin(supabase: ReturnType<typeof createClient>): Promise<{ isAdmin: boolean; error?: string; userId?: string }> {
13
+ const { data: { user }, error: authError } = await supabase.auth.getUser();
14
+ if (authError || !user) {
15
+ return { isAdmin: false, error: "Authentication required." };
16
+ }
17
+ const { data: profile, error: profileError } = await supabase
18
+ .from("profiles")
19
+ .select("role")
20
+ .eq("id", user.id)
21
+ .single();
22
+
23
+ if (profileError || !profile) {
24
+ return { isAdmin: false, error: "Profile not found or error fetching profile." };
25
+ }
26
+ if (profile.role !== "ADMIN") {
27
+ return { isAdmin: false, error: "Admin privileges required." };
28
+ }
29
+ return { isAdmin: true, userId: user.id };
30
+ }
31
+
32
+ type UpdateUserProfilePayload = {
33
+ role: UserRole;
34
+ username?: string | null;
35
+ full_name?: string | null;
36
+ // Add other editable profile fields here if needed
37
+ };
38
+
39
+ export async function updateUserProfile(userIdToUpdate: string, formData: FormData) {
40
+ const supabase = createClient();
41
+ const adminCheck = await verifyAdmin(supabase);
42
+ if (!adminCheck.isAdmin) {
43
+ return { error: adminCheck.error || "Unauthorized" };
44
+ }
45
+
46
+ const rawFormData = {
47
+ role: formData.get("role") as UserRole,
48
+ username: formData.get("username") as string || null,
49
+ full_name: formData.get("full_name") as string || null,
50
+ };
51
+
52
+ if (!rawFormData.role) {
53
+ return { error: "Role is a required field." };
54
+ }
55
+ if (!['ADMIN', 'WRITER', 'USER'].includes(rawFormData.role)) {
56
+ return { error: "Invalid role specified." };
57
+ }
58
+
59
+ // Prevent an admin from accidentally removing their own admin role if they are the only admin
60
+ // This is a basic check; a more robust system might count admins.
61
+ if (userIdToUpdate === adminCheck.userId && rawFormData.role !== 'ADMIN') {
62
+ const { count } = await supabase
63
+ .from('profiles')
64
+ .select('*', { count: 'exact', head: true })
65
+ .eq('role', 'ADMIN');
66
+ if (count === 1) {
67
+ return { error: "Cannot remove the last admin's role." };
68
+ }
69
+ }
70
+
71
+
72
+ const profileData: UpdateUserProfilePayload = {
73
+ role: rawFormData.role,
74
+ username: rawFormData.username,
75
+ full_name: rawFormData.full_name,
76
+ };
77
+
78
+ const { error } = await supabase
79
+ .from("profiles")
80
+ .update(profileData)
81
+ .eq("id", userIdToUpdate);
82
+
83
+ if (error) {
84
+ console.error("Error updating user profile:", error);
85
+ return { error: `Failed to update profile: ${error.message}` };
86
+ }
87
+
88
+ revalidatePath("/cms/users");
89
+ revalidatePath(`/cms/users/${userIdToUpdate}/edit`);
90
+ redirect(`/cms/users/${userIdToUpdate}/edit?success=User profile updated successfully`);
91
+ }
92
+
93
+ export async function deleteUserAndProfile(userIdToDelete: string) {
94
+
95
+ // For deleting a user, we need to use the Supabase Admin API,
96
+ // which requires a client initialized with the SERVICE_ROLE_KEY.
97
+ // This ensures the operation has the necessary privileges.
98
+ // IMPORTANT: Ensure SUPABASE_SERVICE_ROLE_KEY is set in your .env.local and Vercel env vars.
99
+ const supabaseAdmin = createClient(
100
+ // Re-create client with service role. This is a common pattern.
101
+ // Ensure your createClient function can be called without args to use env vars,
102
+ // or pass them explicitly if needed. The one from the template should work.
103
+ // If your createClient is specific to user context (cookies), you might need a separate
104
+ // admin client factory. For now, assuming `createClient()` can make a service client
105
+ // if called in a server action without user cookie context, or if it defaults to service key.
106
+ // A safer way:
107
+ // const supabaseAdmin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
108
+ // However, the template's createClient for server components/actions should handle this by not having cookie access.
109
+ // Let's assume for now createClient() is sufficient if it can use service role.
110
+ // A more explicit way for admin actions:
111
+ // import { createClient as createAdminClient } from '@supabase/supabase-js';
112
+ // const supabaseAdmin = createAdminClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
113
+ );
114
+
115
+
116
+ const adminCheck = await verifyAdmin(supabaseAdmin); // Verify current user is admin
117
+ if (!adminCheck.isAdmin) {
118
+ return { error: adminCheck.error || "Unauthorized" };
119
+ }
120
+
121
+ if (userIdToDelete === adminCheck.userId) {
122
+ return { error: "Admins cannot delete their own account through this panel." };
123
+ }
124
+
125
+ // Use the Supabase Auth Admin API to delete the user
126
+ // This requires the `SERVICE_ROLE_KEY` to be configured for the Supabase client.
127
+ // The standard `createClient` from `utils/supabase/server` might not use the service role by default.
128
+ // You might need a dedicated admin client instance.
129
+ // For this example, we'll assume `supabase.auth.admin.deleteUser` is available and configured.
130
+ // If not, this part needs adjustment to use a service_role client.
131
+
132
+ // The `createClient()` from `@supabase/ssr` for server context doesn't directly expose `auth.admin`.
133
+ // We need to create a standard Supabase client with the service role key.
134
+ const { createClient: createServiceRoleClient } = await import('@supabase/supabase-js');
135
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
136
+ const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
137
+
138
+ if (!supabaseUrl || !serviceRoleKey) {
139
+ throw new Error('Missing required environment variables');
140
+ }
141
+
142
+ const serviceSupabase = createServiceRoleClient(supabaseUrl, serviceRoleKey);
143
+
144
+
145
+ const { error: deletionError } = await serviceSupabase.auth.admin.deleteUser(userIdToDelete);
146
+
147
+ if (deletionError) {
148
+ console.error("Error deleting user:", deletionError);
149
+ // If the profile was deleted by cascade but auth user deletion failed, this is an inconsistent state.
150
+ return { error: `Failed to delete user: ${deletionError.message}` };
151
+ }
152
+
153
+ // The `profiles` table has ON DELETE CASCADE for the user ID, so it should be deleted automatically.
154
+ revalidatePath("/cms/users");
155
+ redirect("/cms/users?success=User deleted successfully");
156
+ }
@@ -0,0 +1,71 @@
1
+ // app/cms/users/components/DeleteUserButton.tsx
2
+ "use client"; // This is crucial
3
+
4
+ import React, { useState, useTransition } from "react";
5
+ import { DropdownMenuItem } from "@nextblock-cms/ui";
6
+ import { Trash2, ShieldAlert } from "lucide-react";
7
+ import { deleteUserAndProfile } from "../actions";
8
+ import { ConfirmationModal } from "@/app/cms/components/ConfirmationModal";
9
+
10
+ export function DeleteUserButtonClient({
11
+ userId,
12
+ userEmail,
13
+ currentAdminId,
14
+ }: {
15
+ userId: string;
16
+ userEmail?: string;
17
+ currentAdminId?: string;
18
+ }) {
19
+ void userEmail;
20
+ const [isPending, startTransition] = useTransition();
21
+ const [isModalOpen, setIsModalOpen] = useState(false);
22
+
23
+ const handleDelete = () => {
24
+ if (userId === currentAdminId) {
25
+ alert("Admins cannot delete their own account through this panel.");
26
+ setIsModalOpen(false);
27
+ return;
28
+ }
29
+ startTransition(async () => {
30
+ const result = await deleteUserAndProfile(userId);
31
+ if (result?.error) {
32
+ alert(`Error: ${result.error}`);
33
+ }
34
+ setIsModalOpen(false);
35
+ });
36
+ };
37
+
38
+ return (
39
+ <>
40
+ <DropdownMenuItem
41
+ className={`text-red-600 hover:!text-red-600 hover:!bg-red-50 dark:hover:!bg-red-700/20 ${
42
+ userId === currentAdminId ? "opacity-50 cursor-not-allowed" : ""
43
+ }`}
44
+ onSelect={(e) => {
45
+ e.preventDefault();
46
+ if (userId !== currentAdminId) setIsModalOpen(true);
47
+ }}
48
+ disabled={userId === currentAdminId}
49
+ >
50
+ <Trash2 className="mr-2 h-4 w-4" />
51
+ Delete User
52
+ {userId === currentAdminId && (
53
+ <span title="You cannot delete your own account.">
54
+ <ShieldAlert
55
+ className="ml-auto h-4 w-4 text-yellow-500"
56
+ aria-label="You cannot delete your own account."
57
+ />
58
+ </span>
59
+ )}
60
+ </DropdownMenuItem>
61
+ <ConfirmationModal
62
+ isOpen={isModalOpen}
63
+ onClose={() => setIsModalOpen(false)}
64
+ onConfirm={handleDelete}
65
+ title="Are you sure?"
66
+ description="This will permanently delete the user. This action cannot be undone."
67
+ confirmText={isPending ? "Deleting..." : "Confirm"}
68
+ />
69
+ </>
70
+ );
71
+ }