create-nextblock 0.2.47 → 0.2.49

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextblock",
3
- "version": "0.2.47",
3
+ "version": "0.2.49",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -5,10 +5,8 @@ import { cn } from "@nextblock-cms/utils";
5
5
  import {
6
6
  Dialog,
7
7
  DialogContent,
8
- DialogHeader,
9
8
  DialogTitle,
10
- DialogFooter,
11
- DialogDescription,
9
+ DialogClose,
12
10
  } from "@nextblock-cms/ui";
13
11
  import { Button } from "@nextblock-cms/ui";
14
12
  import { blockRegistry, type BlockType } from "@/lib/blocks/blockRegistry";
@@ -26,6 +24,8 @@ export type BlockEditorProps<T = unknown> = {
26
24
  block: Block<T>;
27
25
  content: T;
28
26
  onChange: (newContent: T) => void;
27
+ className?: string; // Added for editor component styling
28
+ sectionBackground?: import("@/lib/blocks/blockRegistry").SectionBlockContent['background'];
29
29
  };
30
30
 
31
31
  type BlockEditorModalProps = {
@@ -34,6 +34,7 @@ type BlockEditorModalProps = {
34
34
  onClose: () => void;
35
35
  onSave: (updatedContent: unknown) => void;
36
36
  EditorComponent: LazyExoticComponent<ComponentType<BlockEditorProps<unknown>>> | ComponentType<BlockEditorProps<unknown>>;
37
+ sectionBackground?: import("@/lib/blocks/blockRegistry").SectionBlockContent['background'];
37
38
  };
38
39
 
39
40
  export function BlockEditorModal({
@@ -42,8 +43,10 @@ export function BlockEditorModal({
42
43
  onClose,
43
44
  onSave,
44
45
  EditorComponent,
46
+ sectionBackground,
45
47
  }: BlockEditorModalProps) {
46
48
  const [tempContent, setTempContent] = useState(block.content);
49
+ const isValid = true; // Placeholder for future validation logic
47
50
 
48
51
  useEffect(() => {
49
52
  // When the modal is opened with a new block, reset the temp content
@@ -56,42 +59,78 @@ export function BlockEditorModal({
56
59
  onSave(tempContent);
57
60
  };
58
61
 
62
+ const handleContentChange = (newContent: unknown) => {
63
+ setTempContent(newContent);
64
+ // Potentially add validation here and set isValid
65
+ };
66
+
59
67
  const blockInfo = blockRegistry[block.type];
68
+ const displayText = blockInfo?.label || "Block";
60
69
 
61
70
  return (
62
71
  <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
63
- <DialogContent
64
- className={cn(
65
- "w-[90%] max-w-7xl max-h-[90vh]",
66
- {
67
- // For rich text editor, use fixed height and internal scroll so footer stays visible
68
- "h-[90vh] flex flex-col": block.type === "text",
69
- // For other blocks, allow the modal itself to scroll if content exceeds viewport
70
- "overflow-y-auto": block.type !== "text",
71
- }
72
- )}
72
+ <DialogContent
73
+ className="max-w-6xl h-[90vh] flex flex-col p-0 gap-0 overflow-hidden"
74
+ onInteractOutside={(e) => {
75
+ // Prevent closing when interacting with Tiptap bubbles outside the dialog portal (rare but possible)
76
+ }}
73
77
  >
74
- <DialogHeader>
75
- <DialogTitle>Editing {blockInfo?.label || "Block"}</DialogTitle>
76
- <DialogDescription>
77
- Make changes to your block here. Click save when you&apos;re done.
78
- </DialogDescription>
79
- </DialogHeader>
80
- <div className="py-4 flex-grow flex flex-col min-h-0">
81
- <Suspense fallback={<div className="flex justify-center items-center h-32">Loading editor...</div>}>
82
- <EditorComponent
83
- block={block}
84
- content={tempContent}
85
- onChange={setTempContent}
86
- />
87
- </Suspense>
88
- </div>
89
- <DialogFooter>
90
- <Button variant="outline" onClick={onClose}>
91
- Cancel
92
- </Button>
93
- <Button onClick={handleSave}>Save</Button>
94
- </DialogFooter>
78
+ {/* Header */}
79
+ <div className="flex items-center justify-between p-4 border-b bg-background/95 backdrop-blur z-10">
80
+ <div className="flex items-center gap-2">
81
+ <DialogTitle className="text-lg font-semibold">Edit {displayText}</DialogTitle>
82
+ </div>
83
+ <div className="flex items-center gap-2">
84
+ <DialogClose asChild>
85
+ <Button variant="ghost" size="sm">Cancel</Button>
86
+ </DialogClose>
87
+ <Button onClick={handleSave} disabled={!isValid} size="sm">
88
+ Save (CMD+S)
89
+ </Button>
90
+ </div>
91
+ </div>
92
+
93
+ {/* Editor Area with Contextual Background */}
94
+ <div
95
+ className={cn(
96
+ "flex-1 overflow-y-auto p-6",
97
+ // Conditional Background Logic:
98
+ // Only apply specific section background to 'text' and 'heading' blocks to allow "Live Preview" of copy.
99
+ // For complex blocks like Forms, Buttons, etc., keep a neutral background to ensure input field contrast.
100
+ (block.type === 'text' || block.type === 'heading') ? (
101
+ // If no specific background, use white/dark default
102
+ (!sectionBackground || sectionBackground.type === 'none') && "bg-muted/10"
103
+ ) : "bg-muted/10", // Default for non-text blocks
104
+
105
+ // Apply theme classes if present (ONLY for text/heading)
106
+ (block.type === 'text' || block.type === 'heading') && sectionBackground?.type === 'theme' && sectionBackground.theme === 'primary' && 'bg-primary text-primary-foreground',
107
+ (block.type === 'text' || block.type === 'heading') && sectionBackground?.type === 'theme' && sectionBackground.theme === 'secondary' && 'bg-secondary text-secondary-foreground',
108
+ (block.type === 'text' || block.type === 'heading') && sectionBackground?.type === 'theme' && sectionBackground.theme === 'muted' && 'bg-muted text-muted-foreground',
109
+
110
+ // Dark mode prose invert if dark background (approximate check for solid color)
111
+ (block.type === 'text' || block.type === 'heading') && (sectionBackground?.type === 'solid' && sectionBackground.solid_color && ['#000', '#111', '#0f172a', 'black'].some(c => sectionBackground.solid_color?.includes(c))) && "[&_.prose]:prose-invert"
112
+ )}
113
+ style={{
114
+ // Only apply custom color/gradient styles for text/heading
115
+ backgroundColor: (block.type === 'text' || block.type === 'heading') && sectionBackground?.type === 'solid' ? sectionBackground.solid_color : undefined,
116
+ backgroundImage: (block.type === 'text' || block.type === 'heading') && sectionBackground?.type === 'gradient' && sectionBackground.gradient ?
117
+ `${sectionBackground.gradient.type}-gradient(${sectionBackground.gradient.direction}, ${sectionBackground.gradient.stops.map(s => `${s.color} ${s.position}%`).join(', ')})`
118
+ : undefined
119
+ }}
120
+ >
121
+ <div className="max-w-6xl mx-auto">
122
+ <Suspense fallback={<div className="flex justify-center items-center h-32">Loading editor...</div>}>
123
+ <EditorComponent
124
+ block={block}
125
+ content={tempContent}
126
+ onChange={handleContentChange}
127
+ className="bg-transparent border-none shadow-none focus-within:ring-0 min-h-[60vh]" // Make editor transparent
128
+ sectionBackground={sectionBackground} // Pass down if editor supports it
129
+ />
130
+ </Suspense>
131
+ </div>
132
+ </div>
133
+
95
134
  </DialogContent>
96
135
  </Dialog>
97
136
  );
@@ -1,10 +1,9 @@
1
- // app/cms/blocks/components/ColumnEditor.tsx
2
1
  "use client";
3
2
 
4
3
  import React, { useState, lazy } from 'react';
5
4
  import { cn } from '@nextblock-cms/utils';
6
5
  import { Button } from '@nextblock-cms/ui';
7
- import { PlusCircle, Trash2, Edit2, GripVertical } from "lucide-react";
6
+ import { PlusCircle, Trash2, Edit2, GripVertical, Image as ImageIcon } from "lucide-react";
8
7
  import type { SectionBlockContent } from '@/lib/blocks/blockRegistry';
9
8
  import { availableBlockTypes, getBlockDefinition, getInitialContent, BlockType } from '@/lib/blocks/blockRegistry';
10
9
  import { useDroppable } from "@dnd-kit/core";
@@ -14,7 +13,6 @@ import { BlockEditorModal } from './BlockEditorModal';
14
13
  import { ConfirmationDialog } from '@nextblock-cms/ui';
15
14
  import BlockTypeSelector from './BlockTypeSelector';
16
15
 
17
-
18
16
  type ColumnBlock = SectionBlockContent['column_blocks'][0][0];
19
17
 
20
18
  // Sortable block item component for column blocks
@@ -26,9 +24,10 @@ interface SortableColumnBlockProps {
26
24
  onDelete: () => void;
27
25
  blockType: 'section' | 'hero';
28
26
  onClick: (e: React.MouseEvent<HTMLDivElement>) => void;
27
+ sectionBackground?: SectionBlockContent['background'];
29
28
  }
30
29
 
31
- function SortableColumnBlock({ block, index, columnIndex, onEdit, onDelete, blockType, onClick }: SortableColumnBlockProps) {
30
+ function SortableColumnBlock({ block, index, columnIndex, onEdit, onDelete, blockType, onClick, sectionBackground }: SortableColumnBlockProps) {
32
31
  const {
33
32
  attributes,
34
33
  listeners,
@@ -53,8 +52,215 @@ function SortableColumnBlock({ block, index, columnIndex, onEdit, onDelete, bloc
53
52
  opacity: isDragging ? 0.5 : 1,
54
53
  };
55
54
 
56
- const blockDefinition = getBlockDefinition(block.block_type);
57
- const blockLabel = blockDefinition?.label || block.block_type;
55
+ const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || '';
56
+
57
+ // Helper to check for dark background
58
+ const isDarkBackground = React.useMemo(() => {
59
+ if (!sectionBackground) return false;
60
+
61
+ // Theme checks
62
+ if (sectionBackground.type === 'theme') {
63
+ return ['primary', 'secondary', 'destructive', 'accent', 'dark'].includes(sectionBackground.theme || '');
64
+ }
65
+
66
+ // Image & Gradient - Assume Dark (safe default for overlays)
67
+ if (sectionBackground.type === 'image' || sectionBackground.type === 'gradient') {
68
+ return true;
69
+ }
70
+
71
+ // Solid Color checks
72
+ if (sectionBackground.type === 'solid' && sectionBackground.solid_color) {
73
+ const str = sectionBackground.solid_color.trim();
74
+ let r = 0, g = 0, b = 0;
75
+
76
+ if (str.startsWith('#')) {
77
+ const hex = str.replace('#', '');
78
+ if (hex.length === 3) {
79
+ r = parseInt(hex[0] + hex[0], 16);
80
+ g = parseInt(hex[1] + hex[1], 16);
81
+ b = parseInt(hex[2] + hex[2], 16);
82
+ } else if (hex.length === 6) {
83
+ r = parseInt(hex.substring(0, 2), 16);
84
+ g = parseInt(hex.substring(2, 4), 16);
85
+ b = parseInt(hex.substring(4, 6), 16);
86
+ } else {
87
+ return ['black', 'navy', 'darkblue', 'darkgray'].includes(str.toLowerCase());
88
+ }
89
+ } else if (str.startsWith('rgb')) {
90
+ const matches = str.match(/\d+/g);
91
+ if (matches && matches.length >= 3) {
92
+ r = parseInt(matches[0]);
93
+ g = parseInt(matches[1]);
94
+ b = parseInt(matches[2]);
95
+ } else {
96
+ return false;
97
+ }
98
+ } else {
99
+ return ['black', 'navy', 'darkblue', 'darkgray'].includes(str.toLowerCase());
100
+ }
101
+
102
+ // Calculate luminance (YIQ)
103
+ const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
104
+ return yiq < 128; // Strict threshold for white text
105
+ }
106
+ return false;
107
+ }, [sectionBackground]);
108
+
109
+ // Helper to render content preview
110
+ const renderContentPreview = () => {
111
+ switch (block.block_type) {
112
+ case 'text': {
113
+ // Basic detection of alignment from HTML content to mirror frontend
114
+ const isCentered = block.content.html_content?.includes('text-align: center') || block.content.html_content?.includes('class="text-center"');
115
+ const isRight = block.content.html_content?.includes('text-align: right') || block.content.html_content?.includes('class="text-right"');
116
+ const alignmentClass = isCentered ? 'text-center' : isRight ? 'text-right' : 'text-left';
117
+
118
+ return (
119
+ <div className="flex gap-3">
120
+ <div className={cn("text-xs w-full", alignmentClass)}>
121
+ {block.content.html_content ? (
122
+ <div
123
+ dangerouslySetInnerHTML={{ __html: block.content.html_content }}
124
+ className={cn(
125
+ "prose prose-xs max-w-none [&>p]:my-0 [&>h1]:my-0 [&>h2]:my-0 [&>h3]:my-0",
126
+ isDarkBackground ? "prose-invert text-white/90 drop-shadow-sm" : "dark:prose-invert",
127
+ // Ensure the inner prose content also respects the alignment if not explicitly set on children
128
+ isCentered && "[&_*]:text-center",
129
+ isRight && "[&_*]:text-right"
130
+ )}
131
+ />
132
+ ) : <span className={cn("text-muted-foreground", isDarkBackground && "text-white/50")}>Empty text block</span>}
133
+ </div>
134
+ </div>
135
+ );
136
+ }
137
+ case 'heading': {
138
+ const content = block.content as any;
139
+ const level = content.level || 1;
140
+ const headingAlign = content.textAlign || 'left';
141
+ const textColor = content.textColor || 'foreground';
142
+
143
+ const sizeClasses: Record<number, string> = {
144
+ 1: "text-4xl font-extrabold",
145
+ 2: "text-3xl font-bold",
146
+ 3: "text-2xl font-semibold",
147
+ 4: "text-xl font-semibold",
148
+ 5: "text-lg font-semibold",
149
+ 6: "text-base font-semibold",
150
+ };
151
+
152
+ const colorClasses: Record<string, string> = {
153
+ primary: "text-primary",
154
+ secondary: "text-secondary",
155
+ accent: "text-accent",
156
+ destructive: "text-destructive",
157
+ muted: "text-muted-foreground",
158
+ background: "text-background",
159
+ foreground: "text-foreground"
160
+ };
161
+
162
+ // Override for dark background if color is basic
163
+ let appliedColorClass = colorClasses[textColor] || "text-foreground";
164
+ if (isDarkBackground) {
165
+ if (textColor === 'foreground') appliedColorClass = 'text-white/90';
166
+ if (textColor === 'muted') appliedColorClass = 'text-white/70';
167
+ }
168
+
169
+ return (
170
+ <div className="flex gap-3">
171
+ <div className={cn(
172
+ "w-full leading-tight",
173
+ sizeClasses[level] || sizeClasses[1],
174
+ appliedColorClass,
175
+ headingAlign === 'center' && 'text-center',
176
+ headingAlign === 'right' && 'text-right'
177
+ )}>
178
+ {content.text_content || <span className={cn("text-muted-foreground font-normal text-sm italic", isDarkBackground && "text-white/50")}>Empty heading</span>}
179
+ </div>
180
+ </div>
181
+ );
182
+ }
183
+ case 'image': {
184
+ const imageUrl = block.content.object_key ? `${R2_BASE_URL}/${block.content.object_key}` : block.content.src;
185
+ return (
186
+ <div className="flex gap-3">
187
+ <div className="flex-shrink-0 h-10 w-10 bg-muted/20 rounded overflow-hidden flex items-center justify-center border border-white/10">
188
+ {imageUrl ? (
189
+ /* eslint-disable-next-line @next/next/no-img-element */
190
+ <img src={imageUrl} alt={block.content.alt_text || 'Block image'} className="h-full w-full object-cover" />
191
+ ) : (
192
+ <ImageIcon className={cn("h-5 w-5", isDarkBackground ? "text-white/50" : "text-muted-foreground")} />
193
+ )}
194
+ </div>
195
+ <div className="flex flex-col justify-center">
196
+ <span className="text-xs font-medium truncate max-w-[150px]">{block.content.alt_text || 'No description'}</span>
197
+ <span className={cn("text-[10px] truncate max-w-[150px]", isDarkBackground ? "text-white/50" : "text-muted-foreground")}>{imageUrl ? 'Image set' : 'No image selected'}</span>
198
+ </div>
199
+ </div>
200
+ );
201
+ }
202
+ case 'button': {
203
+ const content = block.content as any;
204
+ return (
205
+ <div className={cn("flex gap-3",
206
+ content.position === 'center' ? 'justify-center' :
207
+ content.position === 'right' ? 'justify-end' : 'justify-start'
208
+ )}>
209
+ <Button
210
+ variant={content.variant || 'default'}
211
+ size={content.size || 'default'}
212
+ className={cn("pointer-events-none", block.content.variant === 'outline' && "text-foreground")}
213
+ tabIndex={-1}
214
+ >
215
+ {content.text || 'Button'}
216
+ </Button>
217
+ </div>
218
+ );
219
+ }
220
+ case 'video_embed':
221
+ return (
222
+ <div className="flex gap-3 items-center">
223
+ <div className="text-xs truncate max-w-[200px]">
224
+ {block.content.title || block.content.url || 'No Video URL'}
225
+ </div>
226
+ </div>
227
+ );
228
+ case 'posts_grid':
229
+ return (
230
+ <div className="flex gap-3 items-center">
231
+ <div className={cn("text-xs", isDarkBackground ? "text-white/70" : "text-muted-foreground")}>
232
+ Posts Grid: {block.content.columns || 3} cols, {block.content.postsPerPage || 12} items
233
+ </div>
234
+ </div>
235
+ );
236
+ case 'testimonial':
237
+ return (
238
+ <div className="flex flex-col gap-2">
239
+ <div className={cn("italic text-xs line-clamp-2", isDarkBackground ? "text-white/80" : "text-muted-foreground")}>
240
+ "{block.content.quote || 'No quote'}"
241
+ </div>
242
+ <div className="flex items-center gap-2">
243
+ {block.content.image_url ? (
244
+ /* eslint-disable-next-line @next/next/no-img-element */
245
+ <img src={block.content.image_url} alt={block.content.author_name} className="w-5 h-5 rounded-full object-cover border border-white/10" />
246
+ ) : (
247
+ <div className={cn("w-5 h-5 rounded-full flex items-center justify-center text-[9px] font-bold border border-white/10", isDarkBackground ? "bg-white/20 text-white" : "bg-muted text-foreground")}>
248
+ {(block.content.author_name || 'A').charAt(0)}
249
+ </div>
250
+ )}
251
+ <div className="flex flex-col leading-none">
252
+ <span className={cn("text-[10px] font-semibold", isDarkBackground ? "text-white" : "text-foreground")}>{block.content.author_name || 'Author Name'}</span>
253
+ {block.content.author_title && <span className={cn("text-[9px]", isDarkBackground ? "text-white/60" : "text-muted-foreground")}>{block.content.author_title}</span>}
254
+ </div>
255
+ </div>
256
+ </div>
257
+ );
258
+ default:
259
+ // For fallback blocks, we might still want the label if we can't render a preview
260
+ // But user asked to remove it. Let's show a generic "configured" message or similar.
261
+ return <div className={cn("text-xs", isDarkBackground ? "text-white/50" : "text-muted-foreground")}>Unknown Block Type</div>;
262
+ }
263
+ };
58
264
 
59
265
  return (
60
266
  <div
@@ -64,45 +270,27 @@ function SortableColumnBlock({ block, index, columnIndex, onEdit, onDelete, bloc
64
270
  {...attributes}
65
271
  {...listeners}
66
272
  className={cn(
67
- "group relative p-2 border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-900 shadow-sm",
68
- "cursor-pointer hover:border-primary"
273
+ "group relative p-3 border border-transparent hover:border-dashed hover:border-primary/50 rounded-lg bg-transparent transition-all",
274
+ "cursor-pointer",
275
+ isDarkBackground ? "text-white border-white/20 hover:border-white/50" : "text-foreground border-transparent"
69
276
  )}
70
277
  >
71
- <div className="flex items-center justify-between mb-1">
72
- <div className="flex items-center gap-2">
73
- <GripVertical className="h-3 w-3 text-gray-400" />
74
- <span className="text-xs font-medium text-gray-600 dark:text-gray-300 capitalize">
75
- {blockLabel}
76
- </span>
77
- </div>
78
- <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
79
- <Button variant="ghost" size="sm" onClick={onEdit} className="h-6 w-6 p-0" title="Edit block">
80
- <Edit2 className="h-3 w-3" />
278
+ {/* Absolute positioning for actions */}
279
+ <div className="absolute top-2 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10 p-1 py-2 hover:bg-gray-500 rounded-lg">
280
+ <div className="cursor-grab active:cursor-grabbing p-1">
281
+ <GripVertical className={cn("h-3 w-3", isDarkBackground ? "text-white drop-shadow-md" : "text-foreground/70")} />
282
+ </div>
283
+ <Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); onEdit(); }} className="h-6 w-6 p-0 hover:bg-transparent" title="Edit block">
284
+ <Edit2 className={cn("h-3 w-3", isDarkBackground ? "text-white drop-shadow-md" : "text-foreground/70")} />
81
285
  </Button>
82
- <Button variant="ghost" size="sm" onClick={onDelete} className="h-6 w-6 p-0 text-red-600 hover:text-red-700" title="Delete block">
286
+ <Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); onDelete(); }} className="h-6 w-6 p-0 text-red-600 hover:text-red-700 hover:bg-transparent" title="Delete block">
83
287
  <Trash2 className="h-3 w-3" />
84
288
  </Button>
85
- </div>
86
289
  </div>
87
- <div className="text-xs text-gray-500 dark:text-gray-400">
88
- {block.block_type === 'text' && (
89
- <div dangerouslySetInnerHTML={{ __html: (block.content.html_content || 'Empty text').substring(0, 50) + (block.content.html_content && block.content.html_content.length > 50 ? '...' : '') }} />
90
- )}
91
- {block.block_type === 'heading' && (
92
- <div>H{block.content.level || 1}: {(block.content.text_content || 'Empty heading').substring(0, 30) + (block.content.text_content && block.content.text_content.length > 30 ? '...' : '')}</div>
93
- )}
94
- {block.block_type === 'image' && (
95
- <div>Image: {block.content.alt_text || block.content.media_id ? 'Image selected' : 'No image selected'}</div>
96
- )}
97
- {block.block_type === 'button' && (
98
- <div>Button: {block.content.text || 'No text'} → {block.content.url || '#'}</div>
99
- )}
100
- {block.block_type === 'video_embed' && (
101
- <div>Video: {block.content.title || block.content.url || 'No URL set'}</div>
102
- )}
103
- {block.block_type === 'posts_grid' && (
104
- <div>Posts Grid: {block.content.columns || 3} cols, {block.content.postsPerPage || 12} posts</div>
105
- )}
290
+
291
+ {/* Live Preview Area - No headers, just content */}
292
+ <div className={cn("min-h-[20px]", isDarkBackground ? "text-white drop-shadow-sm" : "text-foreground")}>
293
+ {renderContentPreview()}
106
294
  </div>
107
295
  </div>
108
296
  );
@@ -114,11 +302,12 @@ export interface ColumnEditorProps {
114
302
  blocks: ColumnBlock[];
115
303
  onBlocksChange: (newBlocks: ColumnBlock[]) => void;
116
304
  blockType: 'section' | 'hero';
305
+ sectionBackground?: SectionBlockContent['background'];
117
306
  }
118
307
 
119
308
  type EditingBlock = ColumnBlock & { index: number };
120
309
 
121
- export default function ColumnEditor({ columnIndex, blocks, onBlocksChange, blockType }: ColumnEditorProps) {
310
+ export default function ColumnEditor({ columnIndex, blocks, onBlocksChange, blockType, sectionBackground }: ColumnEditorProps) {
122
311
  const [editingBlock, setEditingBlock] = useState<EditingBlock | null>(null);
123
312
  const [isBlockSelectorOpen, setIsBlockSelectorOpen] = useState(false);
124
313
  const [LazyEditor, setLazyEditor] = useState<React.LazyExoticComponent<React.ComponentType<any>> | React.ComponentType<any> | null>(null);
@@ -129,6 +318,57 @@ export default function ColumnEditor({ columnIndex, blocks, onBlocksChange, bloc
129
318
  id: `${blockType}-column-droppable-${columnIndex}`,
130
319
  });
131
320
 
321
+ // Duplicate isDark logic (should ideally be shared utils but inline to avoid import cycles)
322
+ const isDarkBackground = React.useMemo(() => {
323
+ if (!sectionBackground) return false;
324
+
325
+ // Theme checks
326
+ if (sectionBackground.type === 'theme') {
327
+ return ['primary', 'secondary', 'destructive', 'accent', 'dark'].includes(sectionBackground.theme || '');
328
+ }
329
+
330
+ // Image & Gradient - Assume Dark (safe default for overlays)
331
+ if (sectionBackground.type === 'image' || sectionBackground.type === 'gradient') {
332
+ return true;
333
+ }
334
+
335
+ // Solid Color checks
336
+ if (sectionBackground.type === 'solid' && sectionBackground.solid_color) {
337
+ const str = sectionBackground.solid_color.trim();
338
+ let r = 0, g = 0, b = 0;
339
+
340
+ if (str.startsWith('#')) {
341
+ const hex = str.replace('#', '');
342
+ if (hex.length === 3) {
343
+ r = parseInt(hex[0] + hex[0], 16);
344
+ g = parseInt(hex[1] + hex[1], 16);
345
+ b = parseInt(hex[2] + hex[2], 16);
346
+ } else if (hex.length === 6) {
347
+ r = parseInt(hex.substring(0, 2), 16);
348
+ g = parseInt(hex.substring(2, 4), 16);
349
+ b = parseInt(hex.substring(4, 6), 16);
350
+ } else {
351
+ return ['black', 'navy', 'darkblue', 'darkgray'].includes(str.toLowerCase());
352
+ }
353
+ } else if (str.startsWith('rgb')) {
354
+ const matches = str.match(/\d+/g);
355
+ if (matches && matches.length >= 3) {
356
+ r = parseInt(matches[0]);
357
+ g = parseInt(matches[1]);
358
+ b = parseInt(matches[2]);
359
+ } else {
360
+ return false;
361
+ }
362
+ } else {
363
+ return ['black', 'navy', 'darkblue', 'darkgray'].includes(str.toLowerCase());
364
+ }
365
+
366
+ const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
367
+ return yiq < 128;
368
+ }
369
+ return false;
370
+ }, [sectionBackground]);
371
+
132
372
  const handleAddBlock = (selectedBlockType: BlockType) => {
133
373
  if (!selectedBlockType) return;
134
374
  const initialContent = getInitialContent(selectedBlockType);
@@ -146,11 +386,7 @@ export default function ColumnEditor({ columnIndex, blocks, onBlocksChange, bloc
146
386
  };
147
387
 
148
388
  const handleCardClick = (e: React.MouseEvent<HTMLDivElement>, block: ColumnBlock, index: number) => {
149
- // Ignore clicks on buttons to prevent conflicts with drag/delete/edit icons.
150
- if ((e.target as HTMLElement).closest('button')) {
151
- return;
152
- }
153
- // Call the existing function to open the modal for this block.
389
+ if ((e.target as HTMLElement).closest('button')) return;
154
390
  handleStartEdit(block, index);
155
391
  };
156
392
 
@@ -202,20 +438,37 @@ export default function ColumnEditor({ columnIndex, blocks, onBlocksChange, bloc
202
438
  };
203
439
 
204
440
  return (
205
- <div className="border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800/50 flex flex-col">
206
- <div className="p-3 border-b border-gray-200 dark:border-gray-700">
441
+ <div className={cn(
442
+ "border border-dashed rounded-lg bg-transparent flex flex-col transition-colors",
443
+ isDarkBackground ? "border-white/30" : "border-gray-300 dark:border-gray-700/50"
444
+ )}>
445
+ <div className={cn(
446
+ "p-3 border-b",
447
+ isDarkBackground ? "border-white/20" : "border-gray-200 dark:border-gray-700"
448
+ )}>
207
449
  <div className="flex items-center justify-between">
208
450
  <div className="flex items-center gap-2">
209
- <h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
451
+ <h4 className={cn(
452
+ "text-sm font-medium",
453
+ isDarkBackground ? "text-white" : "text-gray-700 dark:text-gray-300"
454
+ )}>
210
455
  Column {columnIndex + 1}
211
456
  </h4>
212
- <span className="text-xs text-gray-500 bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded">
213
- {blocks.length} block{blocks.length !== 1 ? 's' : ''}
457
+ <span className={cn(
458
+ "text-xs px-2 py-1 rounded",
459
+ isDarkBackground ? "bg-white/20 text-white" : "text-gray-500 bg-gray-200 dark:bg-gray-700"
460
+ )}>
461
+ {blocks.length}
214
462
  </span>
215
463
  </div>
216
464
  </div>
217
465
  <div className="mt-2">
218
- <Button onClick={() => setIsBlockSelectorOpen(true)} size="sm" className="w-full h-8">
466
+ <Button
467
+ onClick={() => setIsBlockSelectorOpen(true)}
468
+ size="sm"
469
+ variant={isDarkBackground ? "secondary" : "default"}
470
+ className="w-full h-8"
471
+ >
219
472
  <PlusCircle className="h-3 w-3 mr-2" />
220
473
  Add Block
221
474
  </Button>
@@ -225,8 +478,8 @@ export default function ColumnEditor({ columnIndex, blocks, onBlocksChange, bloc
225
478
  {blocks.length === 0 ? (
226
479
  <div
227
480
  ref={setDroppableNodeRef}
228
- className={`h-full flex items-center justify-center text-xs text-gray-500 border-2 border-dashed rounded-lg transition-colors ${
229
- isOver ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-300 dark:border-gray-600'
481
+ className={`h-full flex items-center justify-center text-xs text-muted-foreground border-2 border-dashed rounded-lg transition-colors p-4 ${
482
+ isOver ? 'border-primary bg-primary/5' : 'border-gray-300 dark:border-gray-600'
230
483
  }`}
231
484
  >
232
485
  Drag block here
@@ -243,6 +496,7 @@ export default function ColumnEditor({ columnIndex, blocks, onBlocksChange, bloc
243
496
  onEdit={() => handleStartEdit(block, index)}
244
497
  onDelete={() => handleDeleteBlock(index)}
245
498
  onClick={(e) => handleCardClick(e, block, index)}
499
+ sectionBackground={sectionBackground}
246
500
  />
247
501
  </div>
248
502
  ))}
@@ -262,6 +516,8 @@ export default function ColumnEditor({ columnIndex, blocks, onBlocksChange, bloc
262
516
  content: editingBlock.content,
263
517
  }}
264
518
  EditorComponent={LazyEditor}
519
+ // Pass the section background to the modal
520
+ sectionBackground={sectionBackground}
265
521
  />
266
522
  )}
267
523
  <ConfirmationDialog
@@ -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
  );
@@ -12,6 +12,7 @@ type NotionEditorProps = {
12
12
  content: string;
13
13
  onChange: (html: string) => void;
14
14
  openImagePicker?: () => Promise<{ src: string; alt?: string; width?: number | null; height?: number | null; blurDataURL?: string | null } | null>;
15
+ className?: string;
15
16
  };
16
17
 
17
18
  // Use the alias that resolves in your repo; if you mapped @nextblock-cms/editor, swap it here.
@@ -27,6 +28,7 @@ export type TextBlockContent = {
27
28
  export default function TextBlockEditor({
28
29
  content,
29
30
  onChange,
31
+ className,
30
32
  }: BlockEditorProps<Partial<TextBlockContent>>) {
31
33
  const labelId = useId();
32
34
  const [pickerOpen, setPickerOpen] = useState(false);
@@ -50,6 +52,7 @@ export default function TextBlockEditor({
50
52
  content={content?.html_content ?? ''}
51
53
  onChange={(html) => onChange({ html_content: html })}
52
54
  openImagePicker={openImagePicker}
55
+ className={className}
53
56
  />
54
57
 
55
58
  {/* Hidden controlled MediaPickerDialog for image selection */}
@@ -1,10 +1,14 @@
1
1
  import React from "react";
2
2
  import Link from "next/link";
3
+ import { Button } from "@nextblock-cms/ui";
4
+ import { cn } from "@nextblock-cms/utils";
5
+
3
6
  export type ButtonBlockContent = {
4
7
  text?: string;
5
8
  url?: string;
6
9
  variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'link';
7
- size?: 'default' | 'sm' | 'lg';
10
+ size?: 'default' | 'sm' | 'lg' | 'full';
11
+ position?: 'left' | 'center' | 'right';
8
12
  };
9
13
 
10
14
  interface ButtonBlockRendererProps {
@@ -14,25 +18,7 @@ interface ButtonBlockRendererProps {
14
18
 
15
19
  const ButtonBlockRenderer: React.FC<ButtonBlockRendererProps> = ({
16
20
  content,
17
- // languageId, // Unused
18
21
  }) => {
19
- const baseClasses =
20
- "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50";
21
- const variantClasses: Record<string, string> = {
22
- default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
23
- outline:
24
- "border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
25
- secondary:
26
- "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
27
- ghost: "hover:bg-accent hover:text-accent-foreground",
28
- link: "text-primary underline-offset-4 hover:underline",
29
- };
30
- const sizeClasses: Record<string, string> = {
31
- default: "h-10 px-4 py-2",
32
- sm: "h-9 rounded-md px-3",
33
- lg: "h-11 rounded-md px-8",
34
- };
35
-
36
22
  const isExternal =
37
23
  content.url?.startsWith("http") ||
38
24
  content.url?.startsWith("mailto:") ||
@@ -42,48 +28,54 @@ const ButtonBlockRenderer: React.FC<ButtonBlockRendererProps> = ({
42
28
  const buttonText = content.text || "Button";
43
29
  const buttonVariant = content.variant || "default";
44
30
  const buttonSize = content.size || "default";
31
+ const buttonPosition = content.position || "left";
32
+
33
+ const alignmentClasses = {
34
+ left: "justify-start text-left",
35
+ center: "justify-center text-center",
36
+ right: "justify-end text-right",
37
+ };
45
38
 
46
39
  return (
47
- <div className="my-6 text-center">
40
+ <div className={cn("my-6 flex w-full", alignmentClasses[buttonPosition])}>
48
41
  {/* Case 1: Internal link (not external, not anchor, has URL) */}
49
42
  {!isExternal && !isAnchor && !!content.url ? (
50
- <Link
51
- href={content.url}
52
- className={[
53
- baseClasses,
54
- variantClasses[buttonVariant],
55
- sizeClasses[buttonSize],
56
- ].join(" ")}
43
+ <Button
44
+ asChild
45
+ variant={buttonVariant}
46
+ size={buttonSize}
47
+ className={cn(content.variant === 'outline' && "text-foreground")}
57
48
  >
58
- {buttonText}
59
- </Link>
49
+ <Link href={content.url}>
50
+ {buttonText}
51
+ </Link>
52
+ </Button>
60
53
  ) : /* Case 2: External or Anchor link (has URL) */
61
54
  (isExternal || isAnchor) && !!content.url ? (
62
- <a
63
- href={content.url} // content.url is guaranteed by the condition
64
- target={isExternal ? "_blank" : undefined}
65
- rel={isExternal ? "noopener noreferrer" : undefined}
66
- className={[
67
- baseClasses,
68
- variantClasses[buttonVariant],
69
- sizeClasses[buttonSize],
70
- ].join(" ")}
55
+ <Button
56
+ asChild
57
+ variant={buttonVariant}
58
+ size={buttonSize}
59
+ className={cn(content.variant === 'outline' && "text-foreground")}
71
60
  >
72
- {buttonText}
73
- </a>
61
+ <a
62
+ href={content.url}
63
+ target={isExternal ? "_blank" : undefined}
64
+ rel={isExternal ? "noopener noreferrer" : undefined}
65
+ >
66
+ {buttonText}
67
+ </a>
68
+ </Button>
74
69
  ) : (
75
70
  /* Case 3: No URL or other edge cases - render a plain or disabled button */
76
- <button
77
- type="button"
78
- className={[
79
- baseClasses,
80
- variantClasses[buttonVariant],
81
- sizeClasses[buttonSize],
82
- ].join(" ")}
83
- disabled={!content.url}
71
+ <Button
72
+ variant={buttonVariant}
73
+ size={buttonSize}
74
+ disabled={!content.url}
75
+ className={cn(content.variant === 'outline' && "text-foreground")}
84
76
  >
85
77
  {buttonText}
86
- </button>
78
+ </Button>
87
79
  )}
88
80
  </div>
89
81
  );
@@ -44,7 +44,8 @@ export const ButtonBlockSchema = z.object({
44
44
  text: z.string().describe('The text displayed on the button'),
45
45
  url: z.string().describe('The URL the button links to'),
46
46
  variant: z.enum(['default', 'outline', 'secondary', 'ghost', 'link']).optional().describe('Visual style variant'),
47
- size: z.enum(['default', 'sm', 'lg']).optional().describe('Size of the button'),
47
+ size: z.enum(['default', 'sm', 'lg', 'full']).optional().describe('Size of the button'),
48
+ position: z.enum(['left', 'center', 'right']).optional().describe('Button alignment'),
48
49
  });
49
50
  export type ButtonBlockContent = z.infer<typeof ButtonBlockSchema>;
50
51
 
@@ -281,7 +282,7 @@ export const blockRegistry: Record<BlockType, BlockDefinition> = {
281
282
  type: "button",
282
283
  label: "Button",
283
284
  icon: "SquareMousePointer",
284
- initialContent: { text: "Click Me", url: "#", variant: "default", size: "default" } as ButtonBlockContent,
285
+ initialContent: { text: "Click Me", url: "#", variant: "default", size: "default", position: "left" } as ButtonBlockContent,
285
286
  editorComponentFilename: "ButtonBlockEditor.tsx",
286
287
  rendererComponentFilename: "ButtonBlockRenderer.tsx",
287
288
  schema: ButtonBlockSchema,
@@ -1,6 +1,6 @@
1
1
  /// <reference types="next" />
2
2
  /// <reference types="next/image-types/global" />
3
- import "./.next/types/routes.d.ts";
3
+ import "./.next/dev/types/routes.d.ts";
4
4
 
5
5
  // NOTE: This file should not be edited
6
6
  // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextblock-cms/template",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "dev": "next dev",