create-nextblock 0.2.45 → 0.2.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +45 -27
  3. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +1 -1
  4. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +13 -3
  5. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +11 -4
  6. package/templates/nextblock-template/app/cms/dashboard/actions.ts +98 -0
  7. package/templates/nextblock-template/app/cms/dashboard/page.tsx +76 -153
  8. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +16 -11
  9. package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +23 -12
  10. package/templates/nextblock-template/app/cms/navigation/components/DeleteNavItemButton.tsx +4 -0
  11. package/templates/nextblock-template/app/cms/navigation/components/NavigationItemForm.tsx +30 -6
  12. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +17 -11
  13. package/templates/nextblock-template/app/cms/pages/page.tsx +6 -3
  14. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +18 -12
  15. package/templates/nextblock-template/app/cms/posts/page.tsx +8 -5
  16. package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +18 -5
  17. package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +20 -4
  18. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +33 -7
  19. package/templates/nextblock-template/app/cms/settings/languages/components/DeleteLanguageButton.tsx +3 -3
  20. package/templates/nextblock-template/app/cms/settings/languages/components/LanguageForm.tsx +41 -13
  21. package/templates/nextblock-template/app/cms/settings/languages/page.tsx +15 -13
  22. package/templates/nextblock-template/app/cms/settings/logos/actions.ts +2 -3
  23. package/templates/nextblock-template/app/cms/settings/logos/components/DeleteLogoButton.tsx +50 -0
  24. package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +14 -2
  25. package/templates/nextblock-template/app/cms/settings/logos/page.tsx +3 -6
  26. package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +33 -13
  27. package/templates/nextblock-template/components/BlockRenderer.tsx +14 -1
  28. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +126 -0
  29. package/templates/nextblock-template/docs/How to Create a Custom Block.md +149 -0
  30. package/templates/nextblock-template/hooks/use-hotkeys.ts +27 -0
  31. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +196 -603
  32. package/templates/nextblock-template/next-env.d.ts +1 -1
  33. package/templates/nextblock-template/package.json +1 -1
  34. package/templates/nextblock-template/tsconfig.json +3 -0
  35. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -0
@@ -12,6 +12,7 @@ import {
12
12
  TableRow,
13
13
  } from "@nextblock-cms/ui";
14
14
  import { Badge } from "@nextblock-cms/ui";
15
+ import { Alert, AlertTitle, AlertDescription } from "@nextblock-cms/ui";
15
16
  import { MoreHorizontal, PlusCircle, Edit3, Languages as LanguagesIcon, ShieldAlert } from "lucide-react";
16
17
  import {
17
18
  DropdownMenu,
@@ -66,9 +67,12 @@ export default async function CmsLanguagesListPage() {
66
67
  </div>
67
68
 
68
69
  {successMessage && (
69
- <div className="mb-4 p-3 rounded-md text-sm bg-green-100 text-green-700 border border-green-200">
70
- {decodeURIComponent(successMessage)}
71
- </div>
70
+ <Alert variant="success" className="mb-4">
71
+ <AlertTitle>Success</AlertTitle>
72
+ <AlertDescription>
73
+ {decodeURIComponent(successMessage)}
74
+ </AlertDescription>
75
+ </Alert>
72
76
  )}
73
77
 
74
78
  {languages.length === 0 ? (
@@ -140,16 +144,14 @@ export default async function CmsLanguagesListPage() {
140
144
  </Table>
141
145
  </div>
142
146
  )}
143
- <div className="mt-6 p-4 border border-amber-300 bg-amber-50 dark:bg-amber-900/30 rounded-lg">
144
- <div className="flex items-start">
145
- <ShieldAlert className="h-5 w-5 text-amber-600 dark:text-amber-400 mr-3 flex-shrink-0 mt-0.5" />
146
- <div>
147
- <h4 className="text-sm font-semibold text-amber-700 dark:text-amber-300">Important Note on Deleting Languages</h4>
148
- <p className="text-xs text-amber-600 dark:text-amber-400 mt-1">
149
- Deleting a language is a destructive action. All content (pages, posts, blocks, navigation items) specifically associated with that language will be permanently deleted due to database cascade rules. Please ensure this is intended before proceeding. You cannot delete the current default language if it is the only one.
150
- </p>
151
- </div>
152
- </div>
147
+ <div className="mt-6">
148
+ <Alert variant="warning">
149
+ <ShieldAlert className="h-4 w-4" />
150
+ <AlertTitle>Important Note on Deleting Languages</AlertTitle>
151
+ <AlertDescription>
152
+ Deleting a language is a destructive action. All content (pages, posts, blocks, navigation items) specifically associated with that language will be permanently deleted due to database cascade rules. Please ensure this is intended before proceeding. You cannot delete the current default language if it is the only one.
153
+ </AlertDescription>
154
+ </Alert>
153
155
  </div>
154
156
  </div>
155
157
  );
@@ -51,12 +51,11 @@ export async function deleteLogo(id: string) {
51
51
 
52
52
  if (error) {
53
53
  console.error('Error deleting logo:', error)
54
- // Optionally, handle the error more gracefully
55
- // redirect('/error?message=Could not delete logo')
56
- return
54
+ return { success: false, error: error.message }
57
55
  }
58
56
 
59
57
  revalidatePath('/cms/settings/logos')
58
+ return { success: true }
60
59
  }
61
60
 
62
61
  export async function getLogos() {
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import { DropdownMenuItem } from "@nextblock-cms/ui";
4
+ import { Trash2 } from "lucide-react";
5
+ import { deleteLogo } from "../actions";
6
+ import { useTransition, useState } from 'react';
7
+ import { ConfirmationModal } from "@/app/cms/components/ConfirmationModal";
8
+ import { toast } from 'react-hot-toast';
9
+
10
+ interface DeleteLogoButtonProps {
11
+ logoId: string;
12
+ }
13
+
14
+ export default function DeleteLogoButton({ logoId }: DeleteLogoButtonProps) {
15
+ const [isPending, startTransition] = useTransition();
16
+ const [isModalOpen, setIsModalOpen] = useState(false);
17
+
18
+ const handleDeleteConfirm = () => {
19
+ startTransition(async () => {
20
+ const result = await deleteLogo(logoId);
21
+ if (result?.error) {
22
+ toast.error(`Error: ${result.error}`);
23
+ } else {
24
+ toast.success("Logo deleted successfully");
25
+ }
26
+ setIsModalOpen(false);
27
+ });
28
+ };
29
+
30
+ return (
31
+ <>
32
+ <DropdownMenuItem
33
+ className="text-red-600 hover:!text-red-600 cursor-pointer hover:!bg-red-50 dark:hover:!bg-red-700/20"
34
+ onSelect={(e) => e.preventDefault()}
35
+ onClick={() => !isPending && setIsModalOpen(true)}
36
+ disabled={isPending}
37
+ >
38
+ <Trash2 className="mr-2 h-4 w-4" />
39
+ {isPending ? "Deleting..." : "Delete"}
40
+ </DropdownMenuItem>
41
+ <ConfirmationModal
42
+ isOpen={isModalOpen}
43
+ onClose={() => setIsModalOpen(false)}
44
+ onConfirm={handleDeleteConfirm}
45
+ title="Delete Logo?"
46
+ description="This will permanently delete the logo. This action cannot be undone."
47
+ />
48
+ </>
49
+ );
50
+ }
@@ -6,9 +6,11 @@ import Image from 'next/image'
6
6
  import { Input } from '@nextblock-cms/ui'
7
7
  import { Label } from '@nextblock-cms/ui'
8
8
  import { Button } from '@nextblock-cms/ui'
9
+ import { Alert, AlertDescription, Spinner } from '@nextblock-cms/ui'
9
10
  import type { Database } from '@nextblock-cms/db'
10
11
  import { ImageIcon, X as XIcon } from 'lucide-react'
11
12
  import MediaPickerDialog from '@/app/cms/media/components/MediaPickerDialog'
13
+ import { useHotkeys } from '@/hooks/use-hotkeys'
12
14
  type Media = Database['public']['Tables']['media']['Row'];
13
15
  const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || ''
14
16
 
@@ -96,6 +98,8 @@ export default function LogoForm({ logo, action }: LogoFormProps) {
96
98
  })
97
99
  }
98
100
 
101
+ useHotkeys('ctrl+s', handleSave, [handleSave]);
102
+
99
103
  return (
100
104
  <div className="space-y-6">
101
105
  <div>
@@ -162,10 +166,18 @@ export default function LogoForm({ logo, action }: LogoFormProps) {
162
166
  onClick={handleSave}
163
167
  disabled={isPending || !logoDetails.name || !logoDetails.media_id}
164
168
  >
165
- {isPending ? 'Saving...' : `${logo ? 'Update' : 'Create'} Logo`}
169
+ {isPending ? (
170
+ <>
171
+ <Spinner className="mr-2 h-4 w-4" /> Saving...
172
+ </>
173
+ ) : (
174
+ `${logo ? 'Update' : 'Create'} Logo`
175
+ )}
166
176
  </Button>
167
177
  {formError && (
168
- <div className="text-red-500 text-sm">{formError}</div>
178
+ <Alert variant="destructive" className="py-2 px-4 w-auto inline-flex items-center">
179
+ <AlertDescription>{formError}</AlertDescription>
180
+ </Alert>
169
181
  )}
170
182
  </div>
171
183
  </div>
@@ -17,8 +17,9 @@ import {
17
17
  DropdownMenuTrigger,
18
18
  DropdownMenuSeparator,
19
19
  } from '@nextblock-cms/ui'
20
- import { deleteLogo, getLogos } from './actions'
20
+ import { getLogos } from './actions'
21
21
  import MediaImage from '@/app/cms/media/components/MediaImage'
22
+ import DeleteLogoButton from './components/DeleteLogoButton'
22
23
 
23
24
  const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || ''
24
25
 
@@ -99,11 +100,7 @@ export default async function CmsLogosListPage() {
99
100
  </Link>
100
101
  </DropdownMenuItem>
101
102
  <DropdownMenuSeparator />
102
- <form action={deleteLogo.bind(null, logo.id)}>
103
- <button type="submit" className="w-full text-left px-2 py-1.5 text-sm text-red-500">
104
- Delete
105
- </button>
106
- </form>
103
+ <DeleteLogoButton logoId={logo.id} />
107
104
  </DropdownMenuContent>
108
105
  </DropdownMenu>
109
106
  </TableCell>
@@ -1,9 +1,12 @@
1
1
  // app/cms/users/components/UserForm.tsx
2
2
  "use client";
3
3
 
4
- import { useEffect, useState, useTransition } from "react";
4
+ import { useEffect, useState, useTransition, useRef } from "react";
5
+ import { useHotkeys } from "@/hooks/use-hotkeys";
5
6
  import { useRouter, useSearchParams } from "next/navigation";
6
7
  import { Button } from "@nextblock-cms/ui";
8
+ import { Spinner, Alert, AlertDescription, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@nextblock-cms/ui";
9
+ import { Info } from "lucide-react";
7
10
  import { Input } from "@nextblock-cms/ui";
8
11
  import { Label } from "@nextblock-cms/ui";
9
12
  import {
@@ -85,18 +88,15 @@ export default function UserForm({
85
88
 
86
89
  const userRoles: UserRole[] = ['USER', 'WRITER', 'ADMIN'];
87
90
 
91
+ const formRef = useRef<HTMLFormElement>(null);
92
+ useHotkeys('ctrl+s', () => formRef.current?.requestSubmit());
93
+
88
94
  return (
89
- <form onSubmit={handleSubmit} className="space-y-6">
95
+ <form ref={formRef} onSubmit={handleSubmit} className="space-y-6">
90
96
  {formMessage && (
91
- <div
92
- className={`p-3 rounded-md text-sm ${
93
- formMessage.type === 'success'
94
- ? 'bg-green-100 text-green-700 border border-green-200'
95
- : 'bg-red-100 text-red-700 border border-red-200'
96
- }`}
97
- >
98
- {formMessage.text}
99
- </div>
97
+ <Alert variant={formMessage.type === 'success' ? 'success' : 'destructive'}>
98
+ <AlertDescription>{formMessage.text}</AlertDescription>
99
+ </Alert>
100
100
  )}
101
101
  <div>
102
102
  <Label htmlFor="email">Email (Read-only)</Label>
@@ -114,7 +114,21 @@ export default function UserForm({
114
114
  </div>
115
115
 
116
116
  <div>
117
- <Label htmlFor="role">Role</Label>
117
+ <div className="flex items-center gap-2 mb-2">
118
+ <Label htmlFor="role">Role</Label>
119
+ <TooltipProvider>
120
+ <Tooltip>
121
+ <TooltipTrigger asChild>
122
+ <Info className="h-4 w-4 text-muted-foreground opacity-70 cursor-pointer" />
123
+ </TooltipTrigger>
124
+ <TooltipContent className="max-w-xs">
125
+ <p><strong>ADMIN:</strong> Full access to settings and content.</p>
126
+ <p><strong>WRITER:</strong> Can create/edit content, no settings access.</p>
127
+ <p><strong>USER:</strong> Read-only access.</p>
128
+ </TooltipContent>
129
+ </Tooltip>
130
+ </TooltipProvider>
131
+ </div>
118
132
  <Select name="role" value={role} onValueChange={(value) => setRole(value as UserRole)} required>
119
133
  <SelectTrigger className="mt-1"><SelectValue placeholder="Select role" /></SelectTrigger>
120
134
  <SelectContent>
@@ -130,7 +144,13 @@ export default function UserForm({
130
144
  Cancel
131
145
  </Button>
132
146
  <Button type="submit" disabled={isPending || authLoading}>
133
- {isPending ? "Saving..." : actionButtonText}
147
+ {isPending ? (
148
+ <>
149
+ <Spinner className="mr-2 h-4 w-4" /> Saving...
150
+ </>
151
+ ) : (
152
+ actionButtonText
153
+ )}
134
154
  </Button>
135
155
  </div>
136
156
  </form>
@@ -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
+ };
@@ -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.
@@ -0,0 +1,27 @@
1
+ import { useEffect } from 'react';
2
+
3
+ /**
4
+ * Hook to handle keyboard shortcuts.
5
+ * Currently optimized for 'ctrl+s' / 'meta+s'.
6
+ *
7
+ * @param key The key combination to listen for (e.g. 'ctrl+s')
8
+ * @param callback The function to call when the key combination is pressed
9
+ * @param deps Dependencies array for the effect
10
+ */
11
+ export function useHotkeys(key: string, callback: (event: KeyboardEvent) => void, deps: any[] = []) {
12
+ useEffect(() => {
13
+ const handleKeyDown = (event: KeyboardEvent) => {
14
+ const isCtrl = event.ctrlKey || event.metaKey; // cmd on mac, ctrl on windows
15
+ const keyLower = event.key.toLowerCase();
16
+
17
+ // Check for ctrl+s / cmd+s
18
+ if ((key === 'ctrl+s' || key === 'meta+s') && isCtrl && keyLower === 's') {
19
+ event.preventDefault();
20
+ callback(event);
21
+ }
22
+ };
23
+
24
+ window.addEventListener('keydown', handleKeyDown);
25
+ return () => window.removeEventListener('keydown', handleKeyDown);
26
+ }, [key, ...deps]); // callback should be stable or included in deps if handled by caller
27
+ }