create-nextblock 0.2.46 → 0.2.48

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 (32) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +73 -34
  3. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +309 -53
  4. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +175 -25
  5. package/templates/nextblock-template/app/cms/blocks/editors/ButtonBlockEditor.tsx +44 -26
  6. package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +74 -16
  7. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +2 -0
  8. package/templates/nextblock-template/app/cms/dashboard/actions.ts +98 -0
  9. package/templates/nextblock-template/app/cms/dashboard/page.tsx +76 -153
  10. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +16 -11
  11. package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +23 -12
  12. package/templates/nextblock-template/app/cms/navigation/components/DeleteNavItemButton.tsx +4 -0
  13. package/templates/nextblock-template/app/cms/navigation/components/NavigationItemForm.tsx +30 -6
  14. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +17 -11
  15. package/templates/nextblock-template/app/cms/pages/page.tsx +6 -3
  16. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +18 -12
  17. package/templates/nextblock-template/app/cms/posts/page.tsx +8 -5
  18. package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +18 -5
  19. package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +20 -4
  20. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +33 -7
  21. package/templates/nextblock-template/app/cms/settings/languages/components/DeleteLanguageButton.tsx +3 -3
  22. package/templates/nextblock-template/app/cms/settings/languages/components/LanguageForm.tsx +41 -13
  23. package/templates/nextblock-template/app/cms/settings/languages/page.tsx +15 -13
  24. package/templates/nextblock-template/app/cms/settings/logos/actions.ts +2 -3
  25. package/templates/nextblock-template/app/cms/settings/logos/components/DeleteLogoButton.tsx +50 -0
  26. package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +14 -2
  27. package/templates/nextblock-template/app/cms/settings/logos/page.tsx +3 -6
  28. package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +33 -13
  29. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +41 -49
  30. package/templates/nextblock-template/hooks/use-hotkeys.ts +27 -0
  31. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +3 -2
  32. package/templates/nextblock-template/package.json +1 -1
@@ -6,13 +6,15 @@ import type { Database } from "@nextblock-cms/db";
6
6
  import PostsGridBlockEditor from '../editors/PostsGridBlockEditor';
7
7
 
8
8
  type Block = Database['public']['Tables']['blocks']['Row'];
9
- import { Button } from "@nextblock-cms/ui";
10
- import { GripVertical, Edit2 } from "lucide-react";
9
+ import { Button, Card, CardContent, Avatar, AvatarImage, AvatarFallback } from "@nextblock-cms/ui";
10
+ import { GripVertical, Edit2, Image as ImageIcon, MessageSquareQuote } from "lucide-react";
11
11
  import { getBlockDefinition, blockRegistry, BlockType } from "@/lib/blocks/blockRegistry";
12
12
  import { BlockEditorModal } from './BlockEditorModal';
13
13
  import { DeleteBlockButtonClient } from './DeleteBlockButtonClient';
14
14
  import { cn } from '@nextblock-cms/utils';
15
15
 
16
+ const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || '';
17
+
16
18
  export interface EditableBlockProps {
17
19
  block: Block;
18
20
  onDelete: (blockId: number) => void;
@@ -100,28 +102,174 @@ export default function EditableBlock({
100
102
  return <div className="text-red-500">Error: Block type missing for preview.</div>;
101
103
  }
102
104
 
103
- const blockDefinition = getBlockDefinition(currentBlockType as BlockType);
104
- const blockLabel = blockDefinition?.label || currentBlockType;
105
-
106
- // Default preview for other block types
107
- return (
108
- <div
109
- className="py-4 flex flex-col items-center justify-center space-y-2 min-h-[80px] border border-dashed rounded-md bg-muted/20 cursor-pointer hover:border-primary"
110
- onClick={handleCardClick}
111
- >
112
- <div className="text-center">
113
- <p className="text-sm font-medium text-muted-foreground">{blockLabel}</p>
114
- <p className="text-xs text-muted-foreground">Click edit to modify content</p>
115
- </div>
116
- {/* This button is for non-section blocks which are not yet implemented for inline editing */}
117
- <Button variant="outline" size="sm" onClick={(e) => {
118
- e.stopPropagation();
119
- handleEditClick();
120
- }}>
121
- Edit Block
122
- </Button>
123
- </div>
124
- );
105
+ switch (currentBlockType) {
106
+ case 'text': {
107
+ const content = (block.content || {}) as any;
108
+ const htmlContent = String(content.html_content || '');
109
+ const isCentered = htmlContent.includes('text-align: center') || htmlContent.includes('class="text-center"');
110
+ const isRight = htmlContent.includes('text-align: right') || htmlContent.includes('class="text-right"');
111
+ const alignmentClass = isCentered ? 'text-center' : isRight ? 'text-right' : 'text-left';
112
+
113
+ return (
114
+ <div className="py-2">
115
+ <div className={cn("text-xs w-full", alignmentClass)}>
116
+ {htmlContent ? (
117
+ <div
118
+ dangerouslySetInnerHTML={{ __html: htmlContent }}
119
+ className={cn(
120
+ "prose prose-sm max-w-none [&>p]:my-0 [&>h1]:my-0 [&>h2]:my-0 [&>h3]:my-0 dark:prose-invert",
121
+ isCentered && "[&_*]:text-center",
122
+ isRight && "[&_*]:text-right"
123
+ )}
124
+ />
125
+ ) : <span className="text-muted-foreground italic">Empty text block</span>}
126
+ </div>
127
+ </div>
128
+ );
129
+ }
130
+ case 'heading': {
131
+ const content = (block.content || {}) as any;
132
+ const level = content.level || 1;
133
+ const headingAlign = content.textAlign || 'left';
134
+ const textColor = content.textColor || 'foreground';
135
+
136
+ const sizeClasses: Record<number, string> = {
137
+ 1: "text-4xl font-extrabold",
138
+ 2: "text-3xl font-bold",
139
+ 3: "text-2xl font-semibold",
140
+ 4: "text-xl font-semibold",
141
+ 5: "text-lg font-semibold",
142
+ 6: "text-base font-semibold",
143
+ };
144
+
145
+ const colorClasses: Record<string, string> = {
146
+ primary: "text-primary",
147
+ secondary: "text-secondary",
148
+ accent: "text-accent",
149
+ destructive: "text-destructive",
150
+ muted: "text-muted-foreground",
151
+ background: "text-background",
152
+ foreground: "text-foreground"
153
+ };
154
+
155
+ return (
156
+ <div className="py-2">
157
+ <div className={cn(
158
+ "w-full leading-tight",
159
+ sizeClasses[level] || sizeClasses[1],
160
+ colorClasses[textColor] || "text-foreground",
161
+ headingAlign === 'center' && 'text-center',
162
+ headingAlign === 'right' && 'text-right'
163
+ )}>
164
+ {content.text_content || <span className="text-muted-foreground italic text-sm font-normal">Empty heading</span>}
165
+ </div>
166
+ </div>
167
+ );
168
+ }
169
+ case 'image': {
170
+ const content = (block.content || {}) as any;
171
+ const imageUrl = content.object_key ? `${R2_BASE_URL}/${content.object_key}` : content.src;
172
+ return (
173
+ <div className="flex gap-4 py-2">
174
+ <div className="flex-shrink-0 h-16 w-16 bg-muted rounded overflow-hidden flex items-center justify-center border">
175
+ {imageUrl ? (
176
+ /* eslint-disable-next-line @next/next/no-img-element */
177
+ <img src={imageUrl} alt={content.alt_text || 'Block image'} className="h-full w-full object-cover" />
178
+ ) : (
179
+ <ImageIcon className="h-6 w-6 text-muted-foreground" />
180
+ )}
181
+ </div>
182
+ <div className="flex flex-col justify-center">
183
+ <span className="text-sm font-medium truncate max-w-[200px]">{content.alt_text || 'No description'}</span>
184
+ <span className="text-xs text-muted-foreground truncate max-w-[200px]">{imageUrl ? 'Image set' : 'No image selected'}</span>
185
+ </div>
186
+ </div>
187
+ );
188
+ }
189
+ case 'button': {
190
+ const content = (block.content || {}) as any;
191
+ return (
192
+ <div className={cn("py-2 flex",
193
+ content.position === 'center' ? 'justify-center' :
194
+ content.position === 'right' ? 'justify-end' : 'justify-start'
195
+ )}>
196
+ <Button
197
+ variant={content.variant || 'default'}
198
+ size={content.size || 'default'}
199
+ className={cn("pointer-events-none", content.variant === 'outline' && "text-foreground")}
200
+ tabIndex={-1}
201
+ >
202
+ {content.text || 'Button'}
203
+ </Button>
204
+ </div>
205
+ );
206
+ }
207
+ case 'video_embed': {
208
+ const content = (block.content || {}) as any;
209
+ return (
210
+ <div className="py-2">
211
+ <div className="text-sm text-muted-foreground truncate">
212
+ 📹 {content.title || content.url || 'No Video configured'}
213
+ </div>
214
+ </div>
215
+ );
216
+ }
217
+ case 'posts_grid': {
218
+ const content = (block.content || {}) as any;
219
+ return (
220
+ <div className="py-2">
221
+ <div className="text-sm text-muted-foreground">
222
+ Posts Grid: {content.columns || 3} cols, {content.postsPerPage || 12} items
223
+ </div>
224
+ </div>
225
+ );
226
+ }
227
+ case 'testimonial': {
228
+ const content = (block.content || {}) as any;
229
+ return (
230
+ <div className="py-2">
231
+ <Card className="h-full border-none shadow-none bg-transparent">
232
+ <CardContent className="pt-2 flex flex-col gap-4 h-full p-4">
233
+ <MessageSquareQuote className="w-8 h-8 text-primary/40" />
234
+
235
+ <blockquote className="flex-grow text-lg italic text-muted-foreground leading-relaxed">
236
+ "{content.quote || 'No quote provided'}"
237
+ </blockquote>
238
+
239
+ <div className="flex items-center gap-3 mt-2">
240
+ <Avatar>
241
+ {content.image_url && <AvatarImage src={content.image_url} alt={content.author_name} />}
242
+ <AvatarFallback>{(content.author_name || 'A').slice(0, 2).toUpperCase()}</AvatarFallback>
243
+ </Avatar>
244
+
245
+ <div>
246
+ <div className="font-semibold">{content.author_name || 'Author Name'}</div>
247
+ {content.author_title && (
248
+ <div className="text-sm text-muted-foreground">{content.author_title}</div>
249
+ )}
250
+ </div>
251
+ </div>
252
+ </CardContent>
253
+ </Card>
254
+ </div>
255
+ );
256
+ }
257
+ default: {
258
+ const blockDefinition = getBlockDefinition(currentBlockType as BlockType);
259
+ const blockLabel = blockDefinition?.label || currentBlockType;
260
+ return (
261
+ <div
262
+ className="py-4 flex flex-col items-center justify-center space-y-2 min-h-[80px] border border-dashed rounded-md bg-muted/20 cursor-pointer hover:border-primary"
263
+ onClick={handleCardClick}
264
+ >
265
+ <div className="text-center">
266
+ <p className="text-sm font-medium text-muted-foreground">{blockLabel}</p>
267
+ <p className="text-xs text-muted-foreground">Click edit to modify content</p>
268
+ </div>
269
+ </div>
270
+ );
271
+ }
272
+ }
125
273
  };
126
274
 
127
275
  const isSection = block?.block_type === 'section' || block?.block_type === 'hero';
@@ -129,8 +277,10 @@ export default function EditableBlock({
129
277
 
130
278
  return (
131
279
  <div
280
+ onClick={handleCardClick}
132
281
  className={cn(
133
282
  "p-4 border rounded-lg bg-card shadow",
283
+ !isSection && "cursor-pointer hover:border-primary transition-colors",
134
284
  className
135
285
  )}
136
286
  >
@@ -139,7 +289,7 @@ export default function EditableBlock({
139
289
  <button {...dragHandleProps} className="p-1 rounded-md hover:bg-muted cursor-grab" aria-label="Drag to reorder">
140
290
  <GripVertical className="h-5 w-5" />
141
291
  </button>
142
- <h3 className="font-semibold">{blockDefinition?.label || block.block_type}</h3>
292
+ <h4 className="font-semibold p-0 m-0 mb-1">{blockDefinition?.label || block.block_type}</h4>
143
293
  </div>
144
294
  <div className="flex items-center gap-1">
145
295
  <Button
@@ -11,18 +11,18 @@ export type ButtonBlockContent = {
11
11
  text?: string;
12
12
  url?: string;
13
13
  variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'link';
14
- size?: 'default' | 'sm' | 'lg';
14
+ size?: 'default' | 'sm' | 'lg' | 'full';
15
+ position?: 'left' | 'center' | 'right';
15
16
  };
16
17
 
17
18
  const buttonVariants: ButtonBlockContent['variant'][] = ['default', 'outline', 'secondary', 'ghost', 'link'];
18
- const buttonSizes: ButtonBlockContent['size'][] = ['default', 'sm', 'lg'];
19
+ const buttonSizes: ButtonBlockContent['size'][] = ['default', 'sm', 'lg', 'full'];
20
+ const buttonPositions: ButtonBlockContent['position'][] = ['left', 'center', 'right'];
19
21
 
20
22
 
21
23
  export default function ButtonBlockEditor({ content, onChange }: BlockEditorProps<Partial<ButtonBlockContent>>) {
22
24
 
23
25
  const handleChange = (field: keyof ButtonBlockContent, value: string) => {
24
- // Ensure that when variant or size is cleared, it's set to undefined or a valid default, not an empty string if your type doesn't allow it.
25
- // However, the Select component's onValueChange will provide valid values from the list or an empty string if placeholder is re-selected (which shouldn't happen here).
26
26
  onChange({ ...content, [field]: value });
27
27
  };
28
28
 
@@ -48,34 +48,52 @@ export default function ButtonBlockEditor({ content, onChange }: BlockEditorProp
48
48
  className="mt-1"
49
49
  />
50
50
  </div>
51
- <div>
52
- <Label htmlFor="btn-variant">Variant</Label>
53
- <Select
54
- value={content.variant || "default"}
55
- onValueChange={(val: string) => handleChange('variant', val)}
56
- >
57
- <SelectTrigger id="btn-variant" className="mt-1">
58
- <SelectValue placeholder="Select variant" />
59
- </SelectTrigger>
60
- <SelectContent>
61
- {buttonVariants.filter((v): v is Exclude<ButtonBlockContent['variant'], undefined> => v !== undefined).map(v => (
62
- <SelectItem key={v} value={v}>{v.charAt(0).toUpperCase() + v.slice(1)}</SelectItem>
63
- ))}
64
- </SelectContent>
65
- </Select>
51
+ <div className="grid grid-cols-2 gap-4">
52
+ <div>
53
+ <Label htmlFor="btn-variant">Variant</Label>
54
+ <Select
55
+ value={content.variant || "default"}
56
+ onValueChange={(val: string) => handleChange('variant', val)}
57
+ >
58
+ <SelectTrigger id="btn-variant" className="mt-1">
59
+ <SelectValue placeholder="Select variant" />
60
+ </SelectTrigger>
61
+ <SelectContent>
62
+ {buttonVariants.filter((v): v is Exclude<ButtonBlockContent['variant'], undefined> => v !== undefined).map(v => (
63
+ <SelectItem key={v} value={v}>{v.charAt(0).toUpperCase() + v.slice(1)}</SelectItem>
64
+ ))}
65
+ </SelectContent>
66
+ </Select>
67
+ </div>
68
+ <div>
69
+ <Label htmlFor="btn-size">Size</Label>
70
+ <Select
71
+ value={content.size || "default"}
72
+ onValueChange={(val: string) => handleChange('size', val)}
73
+ >
74
+ <SelectTrigger id="btn-size" className="mt-1">
75
+ <SelectValue placeholder="Select size" />
76
+ </SelectTrigger>
77
+ <SelectContent>
78
+ {buttonSizes.filter((s): s is Exclude<ButtonBlockContent['size'], undefined> => s !== undefined).map(s => (
79
+ <SelectItem key={s} value={s}>{s.toUpperCase()}</SelectItem>
80
+ ))}
81
+ </SelectContent>
82
+ </Select>
83
+ </div>
66
84
  </div>
67
85
  <div>
68
- <Label htmlFor="btn-size">Size</Label>
86
+ <Label htmlFor="btn-position">Alignment</Label>
69
87
  <Select
70
- value={content.size || "default"}
71
- onValueChange={(val: string) => handleChange('size', val)}
88
+ value={content.position || "left"}
89
+ onValueChange={(val: string) => handleChange('position', val)}
72
90
  >
73
- <SelectTrigger id="btn-size" className="mt-1">
74
- <SelectValue placeholder="Select size" />
91
+ <SelectTrigger id="btn-position" className="mt-1">
92
+ <SelectValue placeholder="Select alignment" />
75
93
  </SelectTrigger>
76
94
  <SelectContent>
77
- {buttonSizes.filter((s): s is Exclude<ButtonBlockContent['size'], undefined> => s !== undefined).map(s => (
78
- <SelectItem key={s} value={s}>{s.toUpperCase()}</SelectItem>
95
+ {buttonPositions.filter((p): p is Exclude<ButtonBlockContent['position'], undefined> => p !== undefined).map(p => (
96
+ <SelectItem key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</SelectItem>
79
97
  ))}
80
98
  </SelectContent>
81
99
  </Select>
@@ -38,6 +38,67 @@ interface SectionBlockEditorProps {
38
38
  blockType: 'section' | 'hero';
39
39
  }
40
40
 
41
+ // Background style generator (Mirrors SectionBlockRenderer logic)
42
+ function generateBackgroundStyles(background: SectionBlockContent['background']) {
43
+ const styles: React.CSSProperties = {};
44
+ let className = '';
45
+ const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || "";
46
+
47
+ switch (background?.type) {
48
+ case 'theme': {
49
+ // Theme-based backgrounds using CSS classes
50
+ const themeClasses: Record<string, string> = {
51
+ primary: 'bg-primary text-primary-foreground',
52
+ secondary: 'bg-secondary text-secondary-foreground',
53
+ muted: 'bg-muted text-muted-foreground',
54
+ accent: 'bg-accent text-accent-foreground',
55
+ destructive: 'bg-destructive text-destructive-foreground'
56
+ };
57
+ className = background.theme ? themeClasses[background.theme] || '' : '';
58
+ break;
59
+ }
60
+
61
+ case 'solid':
62
+ if (background.solid_color) {
63
+ styles.backgroundColor = background.solid_color;
64
+ }
65
+ break;
66
+
67
+ case 'gradient':
68
+ if (background.gradient) {
69
+ const { type, direction, stops } = background.gradient;
70
+ const gradientStops = stops.map(stop => `${stop.color} ${stop.position}%`).join(', ');
71
+ styles.background = `${type}-gradient(${direction || 'to right'}, ${gradientStops})`;
72
+ }
73
+ break;
74
+
75
+ case 'image':
76
+ if (background.image) {
77
+ const imageUrl = `${R2_BASE_URL}/${background.image.object_key}`;
78
+ styles.backgroundSize = background.image.size || 'cover';
79
+ styles.backgroundPosition = background.image.position || 'center';
80
+
81
+ let finalBackgroundImage = `url(${imageUrl})`;
82
+
83
+ if (background.image.overlay && background.image.overlay.gradient) {
84
+ const { type, direction, stops } = background.image.overlay.gradient;
85
+ const gradientStops = stops.map(stop => `${stop.color} ${stop.position}%`).join(', ');
86
+ const gradient = `${type}-gradient(${direction || 'to right'}, ${gradientStops})`;
87
+ finalBackgroundImage = `${gradient}, ${finalBackgroundImage}`;
88
+ }
89
+
90
+ styles.backgroundImage = finalBackgroundImage;
91
+ }
92
+ break;
93
+
94
+ default:
95
+ // No background
96
+ break;
97
+ }
98
+
99
+ return { styles, className };
100
+ }
101
+
41
102
  export default function SectionBlockEditor({
42
103
  content,
43
104
  onChange,
@@ -72,6 +133,9 @@ export default function SectionBlockEditor({
72
133
  const [activeId, setActiveId] = useState<string | null>(null);
73
134
  const [draggedBlock, setDraggedBlock] = useState<any>(null);
74
135
 
136
+ // Generate background styles
137
+ const { styles: backgroundStyles, className: backgroundClassName } = generateBackgroundStyles(processedContent.background);
138
+
75
139
  // DND sensors for cross-column dragging
76
140
  const sensors = useSensors(
77
141
  useSensor(PointerSensor, {
@@ -236,13 +300,6 @@ return (
236
300
  )}
237
301
 
238
302
  {/* Column Content Management */}
239
- <div className="space-y-4">
240
- <div className="flex items-center justify-between">
241
- <h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
242
- Column Content
243
- </h3>
244
- </div>
245
-
246
303
  <SortableContext
247
304
  items={(processedContent.column_blocks || [])
248
305
  .flatMap((columnBlocks, columnIndex) =>
@@ -260,14 +317,15 @@ return (
260
317
  strategy={verticalListSortingStrategy}
261
318
  >
262
319
  <div
263
- className={
264
- (processedContent.column_blocks || []).length === 1
265
- ? "block"
266
- : `grid gap-4
267
- grid-cols-${processedContent.responsive_columns.mobile}
268
- md:grid-cols-${processedContent.responsive_columns.tablet}
269
- lg:grid-cols-${processedContent.responsive_columns.desktop}`
270
- }
320
+ className={`grid gap-4 rounded-lg border transition-colors ${backgroundClassName} ${
321
+ (processedContent.column_blocks || []).length === 1
322
+ ? "grid-cols-1"
323
+ : `grid-cols-${processedContent.responsive_columns.mobile} md:grid-cols-${processedContent.responsive_columns.tablet} lg:grid-cols-${processedContent.responsive_columns.desktop}`
324
+ }`}
325
+ style={{
326
+ ...backgroundStyles,
327
+ minHeight: '200px'
328
+ }}
271
329
  >
272
330
  {Array.from({ length: (processedContent.column_blocks || []).length }, (_, columnIndex) => (
273
331
  <ColumnEditor
@@ -278,6 +336,7 @@ return (
278
336
  handleColumnBlocksChange(columnIndex, newBlocks)
279
337
  }
280
338
  blockType={blockType}
339
+ sectionBackground={processedContent.background}
281
340
  />
282
341
  ))}
283
342
  </div>
@@ -332,7 +391,6 @@ return (
332
391
  </div>
333
392
  ) : null}
334
393
  </DragOverlay>
335
- </div>
336
394
  </div>
337
395
  </DndContext>
338
396
  );
@@ -27,6 +27,7 @@ export type TextBlockContent = {
27
27
  export default function TextBlockEditor({
28
28
  content,
29
29
  onChange,
30
+ className,
30
31
  }: BlockEditorProps<Partial<TextBlockContent>>) {
31
32
  const labelId = useId();
32
33
  const [pickerOpen, setPickerOpen] = useState(false);
@@ -50,6 +51,7 @@ export default function TextBlockEditor({
50
51
  content={content?.html_content ?? ''}
51
52
  onChange={(html) => onChange({ html_content: html })}
52
53
  openImagePicker={openImagePicker}
54
+ className={className}
53
55
  />
54
56
 
55
57
  {/* Hidden controlled MediaPickerDialog for image selection */}
@@ -0,0 +1,98 @@
1
+ 'use server'
2
+
3
+ import { createClient } from "@nextblock-cms/db/server";
4
+ import { formatDistanceToNow } from 'date-fns';
5
+
6
+ export type DashboardStats = {
7
+ totalPages: number;
8
+ totalPosts: number;
9
+ totalUsers: number;
10
+ recentContent: {
11
+ type: 'post' | 'page';
12
+ title: string;
13
+ author: string;
14
+ date: string;
15
+ status: string;
16
+ }[];
17
+ scheduledContent: {
18
+ title: string;
19
+ date: string;
20
+ type: string;
21
+ }[];
22
+ };
23
+
24
+ export async function getDashboardStats(): Promise<DashboardStats> {
25
+ const supabase = createClient();
26
+ const now = new Date().toISOString();
27
+
28
+ // Parallelize queries
29
+ const [
30
+ { count: totalPages },
31
+ { count: totalPosts },
32
+ { count: totalUsers },
33
+ { data: recentPosts },
34
+ { data: recentPages },
35
+ { data: scheduledPosts }
36
+ ] = await Promise.all([
37
+ supabase.from('pages').select('*', { count: 'exact', head: true }),
38
+ supabase.from('posts').select('*', { count: 'exact', head: true }),
39
+ supabase.from('profiles').select('*', { count: 'exact', head: true }),
40
+
41
+ // Recent Posts
42
+ supabase.from('posts')
43
+ .select('title, status, updated_at, created_at, profiles(full_name)')
44
+ .order('updated_at', { ascending: false })
45
+ .limit(5),
46
+
47
+ // Recent Pages
48
+ supabase.from('pages')
49
+ .select('title, status, updated_at, created_at')
50
+ .order('updated_at', { ascending: false })
51
+ .limit(5),
52
+
53
+ // Scheduled Posts (published_at > now)
54
+ supabase.from('posts')
55
+ .select('title, published_at')
56
+ .gt('published_at', now)
57
+ .order('published_at', { ascending: true })
58
+ .limit(5)
59
+ ]);
60
+
61
+ // Process Recent Content
62
+ const combinedRecent = [
63
+ ...(recentPosts?.map((p: any) => ({
64
+ type: 'post' as const,
65
+ title: p.title,
66
+ author: p.profiles?.full_name || 'Unknown',
67
+ date: p.updated_at || p.created_at,
68
+ status: p.status
69
+ })) || []),
70
+ ...(recentPages?.map((p: any) => ({
71
+ type: 'page' as const,
72
+ title: p.title,
73
+ author: 'System', // Pages don't always track author in this schema
74
+ date: p.updated_at || p.created_at,
75
+ status: p.status
76
+ })) || [])
77
+ ].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
78
+ .slice(0, 5)
79
+ .map(item => ({
80
+ ...item,
81
+ date: formatDistanceToNow(new Date(item.date), { addSuffix: true })
82
+ }));
83
+
84
+ // Process Scheduled Content
85
+ const processedScheduled = (scheduledPosts || []).map((p: any) => ({
86
+ title: p.title,
87
+ date: new Date(p.published_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }),
88
+ type: 'Post'
89
+ }));
90
+
91
+ return {
92
+ totalPages: totalPages || 0,
93
+ totalPosts: totalPosts || 0,
94
+ totalUsers: totalUsers || 0,
95
+ recentContent: combinedRecent,
96
+ scheduledContent: processedScheduled
97
+ };
98
+ }