create-nextblock 0.2.44 → 0.2.46
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/BlockEditorArea.tsx +45 -27
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +1 -1
- package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +13 -3
- package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +11 -4
- package/templates/nextblock-template/app/providers.tsx +1 -0
- package/templates/nextblock-template/components/BlockRenderer.tsx +14 -1
- package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +126 -0
- package/templates/nextblock-template/components/blocks/renderers/ClientTextBlockRenderer.tsx +5 -0
- package/templates/nextblock-template/components/theme-switcher.tsx +17 -5
- package/templates/nextblock-template/docs/How to Create a Custom Block.md +149 -0
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +196 -603
- package/templates/nextblock-template/next-env.d.ts +1 -1
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/tailwind.config.js +3 -5
- package/templates/nextblock-template/tsconfig.json +4 -1
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -0
package/package.json
CHANGED
|
@@ -143,33 +143,51 @@ export default function BlockEditorArea({ parentId, parentType, initialBlocks, l
|
|
|
143
143
|
let SelectedEditor: React.ComponentType<any> | null = null;
|
|
144
144
|
|
|
145
145
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
146
|
+
// Check block registry for editor component
|
|
147
|
+
const blockDef = getBlockDefinition(blockType);
|
|
148
|
+
|
|
149
|
+
if (blockDef?.EditorComponent) {
|
|
150
|
+
SelectedEditor = blockDef.EditorComponent;
|
|
151
|
+
} else if (blockDef?.editorComponentFilename) {
|
|
152
|
+
// We can't easily do dynamic imports with variable paths inside this useEffect
|
|
153
|
+
// without potentially breaking webpack analysis or needing a different strategy.
|
|
154
|
+
// However, for the core blocks, we have the pre-defined dynamic imports below.
|
|
155
|
+
|
|
156
|
+
switch (blockType) {
|
|
157
|
+
case 'text':
|
|
158
|
+
SelectedEditor = DynamicTextBlockEditor;
|
|
159
|
+
break;
|
|
160
|
+
case 'heading':
|
|
161
|
+
SelectedEditor = DynamicHeadingBlockEditor;
|
|
162
|
+
break;
|
|
163
|
+
case 'image':
|
|
164
|
+
SelectedEditor = DynamicImageBlockEditor;
|
|
165
|
+
break;
|
|
166
|
+
case 'button':
|
|
167
|
+
SelectedEditor = DynamicButtonBlockEditor;
|
|
168
|
+
break;
|
|
169
|
+
case 'posts_grid':
|
|
170
|
+
SelectedEditor = DynamicPostsGridBlockEditor;
|
|
171
|
+
break;
|
|
172
|
+
case 'video_embed':
|
|
173
|
+
SelectedEditor = DynamicVideoEmbedBlockEditor;
|
|
174
|
+
break;
|
|
175
|
+
case 'section':
|
|
176
|
+
SelectedEditor = DynamicSectionBlockEditor;
|
|
177
|
+
break;
|
|
178
|
+
default:
|
|
179
|
+
// Fallback for custom blocks that might use file-based routing but aren't in the switch
|
|
180
|
+
// This might still fail if webpack hasn't bundled them, but it's worth a try or we need to explicitly add them.
|
|
181
|
+
// For the PoC Testimonial block, it has EditorComponent so it hits the first if.
|
|
182
|
+
console.warn(`No dynamic editor configured for nested block type: ${blockType}`);
|
|
183
|
+
alert(`Error: Editor not configured for ${blockType}.`);
|
|
184
|
+
setEditingNestedBlockInfo(null);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
console.warn(`No definition found for nested block type: ${blockType}`);
|
|
189
|
+
setEditingNestedBlockInfo(null);
|
|
190
|
+
return;
|
|
173
191
|
}
|
|
174
192
|
setNestedBlockEditorComponent(() => SelectedEditor);
|
|
175
193
|
setTempNestedBlockContent(JSON.parse(JSON.stringify(editingNestedBlockInfo.blockData.content)));
|
|
@@ -33,7 +33,7 @@ type BlockEditorModalProps = {
|
|
|
33
33
|
isOpen: boolean;
|
|
34
34
|
onClose: () => void;
|
|
35
35
|
onSave: (updatedContent: unknown) => void;
|
|
36
|
-
EditorComponent: LazyExoticComponent<ComponentType<BlockEditorProps<unknown
|
|
36
|
+
EditorComponent: LazyExoticComponent<ComponentType<BlockEditorProps<unknown>>> | ComponentType<BlockEditorProps<unknown>>;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
export function BlockEditorModal({
|
|
@@ -121,7 +121,7 @@ type EditingBlock = ColumnBlock & { index: number };
|
|
|
121
121
|
export default function ColumnEditor({ columnIndex, blocks, onBlocksChange, blockType }: ColumnEditorProps) {
|
|
122
122
|
const [editingBlock, setEditingBlock] = useState<EditingBlock | null>(null);
|
|
123
123
|
const [isBlockSelectorOpen, setIsBlockSelectorOpen] = useState(false);
|
|
124
|
-
const [LazyEditor, setLazyEditor] = useState<React.LazyExoticComponent<React.ComponentType<any>> | null>(null);
|
|
124
|
+
const [LazyEditor, setLazyEditor] = useState<React.LazyExoticComponent<React.ComponentType<any>> | React.ComponentType<any> | null>(null);
|
|
125
125
|
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
|
126
126
|
const [blockToDeleteIndex, setBlockToDeleteIndex] = useState<number | null>(null);
|
|
127
127
|
|
|
@@ -169,8 +169,18 @@ export default function ColumnEditor({ columnIndex, blocks, onBlocksChange, bloc
|
|
|
169
169
|
|
|
170
170
|
const handleStartEdit = (block: ColumnBlock, index: number) => {
|
|
171
171
|
const blockDef = getBlockDefinition(block.block_type);
|
|
172
|
-
if (blockDef
|
|
173
|
-
|
|
172
|
+
if (!blockDef) {
|
|
173
|
+
console.error(`No definition found for block type: ${block.block_type}`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (blockDef.EditorComponent) {
|
|
178
|
+
const Component = blockDef.EditorComponent;
|
|
179
|
+
setLazyEditor(() => Component);
|
|
180
|
+
setEditingBlock({ ...block, index });
|
|
181
|
+
} else if (blockDef.editorComponentFilename) {
|
|
182
|
+
const filename = blockDef.editorComponentFilename;
|
|
183
|
+
const Editor = lazy(() => import(`../editors/${filename.replace(/\.tsx$/, '')}`));
|
|
174
184
|
setLazyEditor(Editor);
|
|
175
185
|
setEditingBlock({ ...block, index });
|
|
176
186
|
} else {
|
|
@@ -34,7 +34,7 @@ export default function EditableBlock({
|
|
|
34
34
|
// Move all hooks to the top before any conditional returns
|
|
35
35
|
const [isConfigPanelOpen, setIsConfigPanelOpen] = useState(false);
|
|
36
36
|
const [editingBlock, setEditingBlock] = useState<Block | null>(null);
|
|
37
|
-
const [LazyEditor, setLazyEditor] = useState<LazyExoticComponent<ComponentType<any>> | null>(null);
|
|
37
|
+
const [LazyEditor, setLazyEditor] = useState<LazyExoticComponent<ComponentType<any>> | ComponentType<any> | null>(null);
|
|
38
38
|
|
|
39
39
|
const SectionEditor = useMemo(() => {
|
|
40
40
|
if (block?.block_type === 'section' || block?.block_type === 'hero') {
|
|
@@ -58,14 +58,21 @@ export default function EditableBlock({
|
|
|
58
58
|
if (block.block_type === 'section' || block.block_type === 'hero') {
|
|
59
59
|
setIsConfigPanelOpen(prev => !prev);
|
|
60
60
|
} else {
|
|
61
|
-
const
|
|
61
|
+
const blockDef = getBlockDefinition(block.block_type as BlockType);
|
|
62
|
+
|
|
62
63
|
if (block.block_type === 'posts_grid') {
|
|
63
64
|
const LazifiedPostsGridEditor = lazy(() => Promise.resolve({ default: PostsGridBlockEditor }));
|
|
64
65
|
setLazyEditor(LazifiedPostsGridEditor);
|
|
65
66
|
setEditingBlock(block);
|
|
66
67
|
}
|
|
67
|
-
else if (
|
|
68
|
-
const
|
|
68
|
+
else if (blockDef?.EditorComponent) {
|
|
69
|
+
const Component = blockDef.EditorComponent;
|
|
70
|
+
setLazyEditor(() => Component);
|
|
71
|
+
setEditingBlock(block);
|
|
72
|
+
}
|
|
73
|
+
else if (blockDef?.editorComponentFilename) {
|
|
74
|
+
const filename = blockDef.editorComponentFilename;
|
|
75
|
+
const Editor = lazy(() => import(`../editors/${filename.replace(/\.tsx$/, '')}`));
|
|
69
76
|
setLazyEditor(Editor);
|
|
70
77
|
setEditingBlock(block);
|
|
71
78
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// components/BlockRenderer.tsx
|
|
1
|
+
// components/BlockRenderer.tsx
|
|
2
2
|
import React from "react";
|
|
3
3
|
import dynamic from "next/dynamic";
|
|
4
4
|
import type { Database } from "@nextblock-cms/db";
|
|
@@ -46,6 +46,19 @@ const DynamicBlockRenderer: React.FC<DynamicBlockRendererProps> = ({
|
|
|
46
46
|
return <ClientTextBlockRenderer content={block.content as any} languageId={languageId} />;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// Check if the block definition provides a direct component (e.g., from SDK plugins)
|
|
50
|
+
if (blockDefinition.RendererComponent) {
|
|
51
|
+
const RendererComponent = blockDefinition.RendererComponent;
|
|
52
|
+
return (
|
|
53
|
+
<RendererComponent
|
|
54
|
+
content={block.content}
|
|
55
|
+
languageId={languageId}
|
|
56
|
+
isInEditor={false} // Assuming public view
|
|
57
|
+
className="my-4"
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
49
62
|
// Create dynamic component with proper SSR handling for other blocks
|
|
50
63
|
const RendererComponent = dynamic(
|
|
51
64
|
() => import(`./blocks/renderers/${blockDefinition.rendererComponentFilename}`),
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { BlockConfig, BlockProps, BlockEditorProps } from '@nextblock-cms/sdk';
|
|
4
|
+
import { Card, CardContent, Avatar, AvatarImage, AvatarFallback, Input, Label, Textarea } from '@nextblock-cms/ui';
|
|
5
|
+
import { MessageSquareQuote } from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
// 1. Define the Schema
|
|
8
|
+
export const TestimonialSchema = z.object({
|
|
9
|
+
quote: z.string().min(1).describe('The testimonial text'),
|
|
10
|
+
author_name: z.string().min(1).describe('The person who gave the testimonial'),
|
|
11
|
+
author_title: z.string().optional().describe('Job title or company'),
|
|
12
|
+
image_url: z.string().url().optional().or(z.literal('')).describe('Author profile image URL'),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// 2. Derive the Content Type
|
|
16
|
+
export type TestimonialBlockContent = z.infer<typeof TestimonialSchema>;
|
|
17
|
+
|
|
18
|
+
// 3. Create the Renderer Component
|
|
19
|
+
const TestimonialBlockRenderer: React.FC<BlockProps<typeof TestimonialSchema>> = ({ content }) => {
|
|
20
|
+
return (
|
|
21
|
+
<div className="container m-8">
|
|
22
|
+
<Card className="h-full">
|
|
23
|
+
<CardContent className="pt-6 flex flex-col gap-4 h-full">
|
|
24
|
+
<MessageSquareQuote className="w-8 h-8 text-primary/40" />
|
|
25
|
+
|
|
26
|
+
<blockquote className="flex-grow text-lg italic text-muted-foreground">
|
|
27
|
+
"{content.quote}"
|
|
28
|
+
</blockquote>
|
|
29
|
+
|
|
30
|
+
<div className="flex items-center gap-3 mt-4">
|
|
31
|
+
<Avatar>
|
|
32
|
+
{content.image_url && <AvatarImage src={content.image_url} alt={content.author_name} />}
|
|
33
|
+
<AvatarFallback>{content.author_name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
34
|
+
</Avatar>
|
|
35
|
+
|
|
36
|
+
<div>
|
|
37
|
+
<div className="font-semibold">{content.author_name}</div>
|
|
38
|
+
{content.author_title && (
|
|
39
|
+
<div className="text-sm text-muted-foreground">{content.author_title}</div>
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</CardContent>
|
|
44
|
+
</Card>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// 4. Create the Editor Component
|
|
50
|
+
const TestimonialBlockEditor: React.FC<BlockEditorProps<typeof TestimonialSchema>> = ({ content, onChange }) => {
|
|
51
|
+
const handleChange = (key: keyof TestimonialBlockContent, value: string) => {
|
|
52
|
+
onChange({
|
|
53
|
+
...content,
|
|
54
|
+
[key]: value,
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="space-y-4 p-1">
|
|
60
|
+
<div className="space-y-2">
|
|
61
|
+
<Label htmlFor="quote">Quote</Label>
|
|
62
|
+
<Textarea
|
|
63
|
+
id="quote"
|
|
64
|
+
value={content.quote}
|
|
65
|
+
onChange={(e) => handleChange('quote', e.target.value)}
|
|
66
|
+
placeholder="Enter the testimonial quote..."
|
|
67
|
+
rows={4}
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div className="space-y-2">
|
|
72
|
+
<Label htmlFor="author_name">Author Name</Label>
|
|
73
|
+
<Input
|
|
74
|
+
id="author_name"
|
|
75
|
+
value={content.author_name}
|
|
76
|
+
onChange={(e) => handleChange('author_name', e.target.value)}
|
|
77
|
+
placeholder="John Doe"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div className="space-y-2">
|
|
82
|
+
<Label htmlFor="author_title">Author Title (Optional)</Label>
|
|
83
|
+
<Input
|
|
84
|
+
id="author_title"
|
|
85
|
+
value={content.author_title || ''}
|
|
86
|
+
onChange={(e) => handleChange('author_title', e.target.value)}
|
|
87
|
+
placeholder="CEO, Company Inc."
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div className="space-y-2">
|
|
92
|
+
<Label htmlFor="image_url">Author Image URL (Optional)</Label>
|
|
93
|
+
<div className="flex gap-2">
|
|
94
|
+
<Input
|
|
95
|
+
id="image_url"
|
|
96
|
+
value={content.image_url || ''}
|
|
97
|
+
onChange={(e) => handleChange('image_url', e.target.value)}
|
|
98
|
+
placeholder="https://example.com/image.jpg"
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
{content.image_url && (
|
|
102
|
+
<div className="mt-2 w-16 h-16 relative rounded-full overflow-hidden border">
|
|
103
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
104
|
+
<img src={content.image_url} alt="Preview" className="w-full h-full object-cover" />
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// 5. Export the Block Configuration
|
|
113
|
+
export const TestimonialBlockConfig: BlockConfig<typeof TestimonialSchema> = {
|
|
114
|
+
type: 'testimonial',
|
|
115
|
+
label: 'Testimonial',
|
|
116
|
+
icon: MessageSquareQuote,
|
|
117
|
+
schema: TestimonialSchema,
|
|
118
|
+
initialContent: {
|
|
119
|
+
quote: "This product changed my life! The workflow is so much smoother now.",
|
|
120
|
+
author_name: "Jane Doe",
|
|
121
|
+
author_title: "CEO, TechCorp",
|
|
122
|
+
image_url: "",
|
|
123
|
+
},
|
|
124
|
+
RendererComponent: TestimonialBlockRenderer,
|
|
125
|
+
EditorComponent: TestimonialBlockEditor,
|
|
126
|
+
};
|
package/templates/nextblock-template/components/blocks/renderers/ClientTextBlockRenderer.tsx
CHANGED
|
@@ -16,6 +16,11 @@ const ClientTextBlockRenderer: React.FC<ClientTextBlockRendererProps> = ({ conte
|
|
|
16
16
|
const options: HTMLReactParserOptions = {
|
|
17
17
|
replace: (domNode) => {
|
|
18
18
|
if (domNode instanceof Element && domNode.attribs) {
|
|
19
|
+
if (domNode.attribs['fetchpriority']) {
|
|
20
|
+
domNode.attribs['fetchPriority'] = domNode.attribs['fetchpriority'];
|
|
21
|
+
delete domNode.attribs['fetchpriority'];
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
if (domNode.attribs['data-alert-widget'] !== undefined) {
|
|
20
25
|
const {
|
|
21
26
|
'data-type': type,
|
|
@@ -8,13 +8,15 @@ import {
|
|
|
8
8
|
DropdownMenuRadioItem,
|
|
9
9
|
DropdownMenuTrigger,
|
|
10
10
|
} from "@nextblock-cms/ui";
|
|
11
|
-
import { Laptop, Moon, Sun } from "lucide-react";
|
|
11
|
+
import { Laptop, Moon, Sun, Zap } from "lucide-react";
|
|
12
12
|
import { useTheme } from "next-themes";
|
|
13
13
|
import { useEffect, useState } from "react";
|
|
14
|
+
import { useTranslations } from "@nextblock-cms/utils";
|
|
14
15
|
|
|
15
16
|
const ThemeSwitcher = () => {
|
|
16
17
|
const [mounted, setMounted] = useState(false);
|
|
17
18
|
const { theme, setTheme } = useTheme();
|
|
19
|
+
const { t } = useTranslations();
|
|
18
20
|
|
|
19
21
|
// useEffect only runs on the client, so now we can safely show the UI
|
|
20
22
|
useEffect(() => {
|
|
@@ -30,7 +32,7 @@ const ThemeSwitcher = () => {
|
|
|
30
32
|
return (
|
|
31
33
|
<DropdownMenu>
|
|
32
34
|
<DropdownMenuTrigger asChild>
|
|
33
|
-
<Button variant="ghost" size={"sm"} aria-label=
|
|
35
|
+
<Button variant="ghost" size={"sm"} aria-label={t('theme_switcher')}>
|
|
34
36
|
{theme === "light" ? (
|
|
35
37
|
<Sun
|
|
36
38
|
key="light"
|
|
@@ -43,6 +45,12 @@ const ThemeSwitcher = () => {
|
|
|
43
45
|
size={ICON_SIZE}
|
|
44
46
|
className={"text-muted-foreground"}
|
|
45
47
|
/>
|
|
48
|
+
) : theme === "vibrant" ? (
|
|
49
|
+
<Zap
|
|
50
|
+
key="vibrant"
|
|
51
|
+
size={ICON_SIZE}
|
|
52
|
+
className={"text-muted-foreground"}
|
|
53
|
+
/>
|
|
46
54
|
) : (
|
|
47
55
|
<Laptop
|
|
48
56
|
key="system"
|
|
@@ -59,15 +67,19 @@ const ThemeSwitcher = () => {
|
|
|
59
67
|
>
|
|
60
68
|
<DropdownMenuRadioItem className="flex gap-2" value="light">
|
|
61
69
|
<Sun size={ICON_SIZE} className="text-muted-foreground" />{" "}
|
|
62
|
-
<span>
|
|
70
|
+
<span>{t('theme_light')}</span>
|
|
63
71
|
</DropdownMenuRadioItem>
|
|
64
72
|
<DropdownMenuRadioItem className="flex gap-2" value="dark">
|
|
65
73
|
<Moon size={ICON_SIZE} className="text-muted-foreground" />{" "}
|
|
66
|
-
<span>
|
|
74
|
+
<span>{t('theme_dark')}</span>
|
|
75
|
+
</DropdownMenuRadioItem>
|
|
76
|
+
<DropdownMenuRadioItem className="flex gap-2" value="vibrant">
|
|
77
|
+
<Zap size={ICON_SIZE} className="text-muted-foreground" />{" "}
|
|
78
|
+
<span>{t('theme_vibrant')}</span>
|
|
67
79
|
</DropdownMenuRadioItem>
|
|
68
80
|
<DropdownMenuRadioItem className="flex gap-2" value="system">
|
|
69
81
|
<Laptop size={ICON_SIZE} className="text-muted-foreground" />{" "}
|
|
70
|
-
<span>
|
|
82
|
+
<span>{t('theme_system')}</span>
|
|
71
83
|
</DropdownMenuRadioItem>
|
|
72
84
|
</DropdownMenuRadioGroup>
|
|
73
85
|
</DropdownMenuContent>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# How to Create a Custom Block with the NextBlock SDK
|
|
2
|
+
|
|
3
|
+
Custom blocks allow you to extend the functionality of NextBlock CMS while ensuring type safety and seamless integration. The `@nextblock-cms/sdk` package provides the necessary tools and interfaces.
|
|
4
|
+
|
|
5
|
+
## Step 1: Define the Schema (Zod)
|
|
6
|
+
|
|
7
|
+
The schema defines the structure of your block's content. It is used for validation and to generate TypeScript types.
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
|
|
12
|
+
export const MyBlockSchema = z.object({
|
|
13
|
+
title: z.string().min(1).describe('The main title'),
|
|
14
|
+
description: z.string().optional().describe('Optional description'),
|
|
15
|
+
isActive: z.boolean().default(true),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Derive the content type from the schema
|
|
19
|
+
export type MyBlockContent = z.infer<typeof MyBlockSchema>;
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Step 2: Define the Components (React/TS)
|
|
23
|
+
|
|
24
|
+
Create the components that will render your block. Use the `BlockProps` interface to ensure your component receives the correct data.
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import React from 'react';
|
|
28
|
+
import { BlockProps } from '@nextblock-cms/sdk';
|
|
29
|
+
|
|
30
|
+
// The Renderer Component (Public View)
|
|
31
|
+
const MyBlockRenderer: React.FC<BlockProps<typeof MyBlockSchema>> = ({ content }) => {
|
|
32
|
+
return (
|
|
33
|
+
<div className="my-block">
|
|
34
|
+
<h2>{content.title}</h2>
|
|
35
|
+
{content.description && <p>{content.description}</p>}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// The Editor Component (CMS View)
|
|
41
|
+
// Currently, the CMS uses auto-generated forms based on the schema,
|
|
42
|
+
// but you can provide a custom preview or editor here.
|
|
43
|
+
const MyBlockEditor: React.FC<BlockProps<typeof MyBlockSchema>> = ({ content }) => {
|
|
44
|
+
return (
|
|
45
|
+
<div className="p-4 border">
|
|
46
|
+
Preview: {content.title}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Step 3: Create the Configuration
|
|
53
|
+
|
|
54
|
+
Combine everything into a `BlockConfig` object. This object tells the CMS how to handle your block.
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
import { BlockConfig } from '@nextblock-cms/sdk';
|
|
58
|
+
import { Star } from 'lucide-react'; // Choose an icon
|
|
59
|
+
|
|
60
|
+
export const MyBlockConfig: BlockConfig<typeof MyBlockSchema> = {
|
|
61
|
+
type: 'my_block', // Unique identifier
|
|
62
|
+
label: 'My Custom Block',
|
|
63
|
+
icon: Star,
|
|
64
|
+
schema: MyBlockSchema,
|
|
65
|
+
initialContent: {
|
|
66
|
+
title: 'Default Title',
|
|
67
|
+
isActive: true,
|
|
68
|
+
},
|
|
69
|
+
RendererComponent: MyBlockRenderer,
|
|
70
|
+
EditorComponent: MyBlockEditor,
|
|
71
|
+
};
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Step 4: Building the Editor Component
|
|
75
|
+
|
|
76
|
+
The Editor Component is responsible for the editing experience within the CMS. It receives `BlockEditorProps`, which includes the current content and an `onChange` handler.
|
|
77
|
+
|
|
78
|
+
You should use the shared UI components from `@nextblock-cms/ui` (like `Input`, `Textarea`, `Button`, `Label`) to ensure a consistent look and feel with the rest of the Admin UI.
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
import React from 'react';
|
|
82
|
+
import { BlockEditorProps } from '@nextblock-cms/sdk';
|
|
83
|
+
import { Input, Label, Textarea } from '@nextblock-cms/ui';
|
|
84
|
+
|
|
85
|
+
const MyBlockEditor: React.FC<BlockEditorProps<typeof MyBlockSchema>> = ({ content, onChange }) => {
|
|
86
|
+
|
|
87
|
+
const handleChange = (key: keyof typeof content, value: any) => {
|
|
88
|
+
onChange({
|
|
89
|
+
...content,
|
|
90
|
+
[key]: value,
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className="space-y-4">
|
|
96
|
+
<div className="space-y-2">
|
|
97
|
+
<Label htmlFor="title">Title</Label>
|
|
98
|
+
<Input
|
|
99
|
+
id="title"
|
|
100
|
+
value={content.title}
|
|
101
|
+
onChange={(e) => handleChange('title', e.target.value)}
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div className="space-y-2">
|
|
106
|
+
<Label htmlFor="description">Description</Label>
|
|
107
|
+
<Textarea
|
|
108
|
+
id="description"
|
|
109
|
+
value={content.description || ''}
|
|
110
|
+
onChange={(e) => handleChange('description', e.target.value)}
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Step 5: Register the Block
|
|
119
|
+
|
|
120
|
+
To make your block available in the CMS, import it into the main registry file: `apps/nextblock/lib/blocks/blockRegistry.ts`.
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
// apps/nextblock/lib/blocks/blockRegistry.ts
|
|
124
|
+
import { MyBlockConfig } from '../../components/blocks/MyBlock';
|
|
125
|
+
|
|
126
|
+
export const blockRegistry: Record<BlockType, BlockDefinition> = {
|
|
127
|
+
// ... existing blocks
|
|
128
|
+
[MyBlockConfig.type]: {
|
|
129
|
+
...MyBlockConfig,
|
|
130
|
+
// ... any additional internal config if needed
|
|
131
|
+
} as BlockDefinition<MyBlockContent>,
|
|
132
|
+
};
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Once registered, your block will appear in the CMS block picker and render on the frontend.
|
|
136
|
+
|
|
137
|
+
## Advanced Topic: Schema Evolution and Versioning
|
|
138
|
+
|
|
139
|
+
Modifying a Zod schema after a block is in production can be dangerous. If you change the shape of the data (e.g., rename a field, remove a required field), existing blocks in the database may fail to validate or render, causing runtime errors.
|
|
140
|
+
|
|
141
|
+
### Recommended Strategy: Versioning
|
|
142
|
+
|
|
143
|
+
Instead of modifying the existing schema, create a new version of the block.
|
|
144
|
+
|
|
145
|
+
1. **Duplicate the Block**: Create a new block type, e.g., `my_block_v2`, with the updated schema and renderer.
|
|
146
|
+
2. **Register the New Block**: Add it to the registry. You can hide the old `my_block` from the picker if you want to prevent new usage, but keep the definition so existing blocks continue to work.
|
|
147
|
+
3. **Migration (Optional)**: You can write a utility to transform `my_block` data to `my_block_v2` format if you want to migrate content programmatically.
|
|
148
|
+
|
|
149
|
+
While V1 of the SDK does not enforce a strict versioning system, treating your block schemas as immutable contracts is a best practice for stability.
|