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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +73 -34
- package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +309 -53
- package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +175 -25
- package/templates/nextblock-template/app/cms/blocks/editors/ButtonBlockEditor.tsx +44 -26
- package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +74 -16
- package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +2 -0
- package/templates/nextblock-template/app/cms/dashboard/actions.ts +98 -0
- package/templates/nextblock-template/app/cms/dashboard/page.tsx +76 -153
- package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +16 -11
- package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +23 -12
- package/templates/nextblock-template/app/cms/navigation/components/DeleteNavItemButton.tsx +4 -0
- package/templates/nextblock-template/app/cms/navigation/components/NavigationItemForm.tsx +30 -6
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +17 -11
- package/templates/nextblock-template/app/cms/pages/page.tsx +6 -3
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +18 -12
- package/templates/nextblock-template/app/cms/posts/page.tsx +8 -5
- package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +18 -5
- package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +20 -4
- package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +33 -7
- package/templates/nextblock-template/app/cms/settings/languages/components/DeleteLanguageButton.tsx +3 -3
- package/templates/nextblock-template/app/cms/settings/languages/components/LanguageForm.tsx +41 -13
- package/templates/nextblock-template/app/cms/settings/languages/page.tsx +15 -13
- package/templates/nextblock-template/app/cms/settings/logos/actions.ts +2 -3
- package/templates/nextblock-template/app/cms/settings/logos/components/DeleteLogoButton.tsx +50 -0
- package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +14 -2
- package/templates/nextblock-template/app/cms/settings/logos/page.tsx +3 -6
- package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +33 -13
- package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +41 -49
- package/templates/nextblock-template/hooks/use-hotkeys.ts +27 -0
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +3 -2
- 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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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-
|
|
86
|
+
<Label htmlFor="btn-position">Alignment</Label>
|
|
69
87
|
<Select
|
|
70
|
-
value={content.
|
|
71
|
-
onValueChange={(val: string) => handleChange('
|
|
88
|
+
value={content.position || "left"}
|
|
89
|
+
onValueChange={(val: string) => handleChange('position', val)}
|
|
72
90
|
>
|
|
73
|
-
<SelectTrigger id="btn-
|
|
74
|
-
<SelectValue placeholder="Select
|
|
91
|
+
<SelectTrigger id="btn-position" className="mt-1">
|
|
92
|
+
<SelectValue placeholder="Select alignment" />
|
|
75
93
|
</SelectTrigger>
|
|
76
94
|
<SelectContent>
|
|
77
|
-
{
|
|
78
|
-
<SelectItem key={
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
+
}
|