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.
- 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/cms/dashboard/actions.ts +98 -0
- package/templates/nextblock-template/app/cms/dashboard/page.tsx +76 -153
- package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +16 -11
- package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +23 -12
- package/templates/nextblock-template/app/cms/navigation/components/DeleteNavItemButton.tsx +4 -0
- package/templates/nextblock-template/app/cms/navigation/components/NavigationItemForm.tsx +30 -6
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +17 -11
- package/templates/nextblock-template/app/cms/pages/page.tsx +6 -3
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +18 -12
- package/templates/nextblock-template/app/cms/posts/page.tsx +8 -5
- package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +18 -5
- package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +20 -4
- package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +33 -7
- package/templates/nextblock-template/app/cms/settings/languages/components/DeleteLanguageButton.tsx +3 -3
- package/templates/nextblock-template/app/cms/settings/languages/components/LanguageForm.tsx +41 -13
- package/templates/nextblock-template/app/cms/settings/languages/page.tsx +15 -13
- package/templates/nextblock-template/app/cms/settings/logos/actions.ts +2 -3
- package/templates/nextblock-template/app/cms/settings/logos/components/DeleteLogoButton.tsx +50 -0
- package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +14 -2
- package/templates/nextblock-template/app/cms/settings/logos/page.tsx +3 -6
- package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +33 -13
- package/templates/nextblock-template/components/BlockRenderer.tsx +14 -1
- package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +126 -0
- package/templates/nextblock-template/docs/How to Create a Custom Block.md +149 -0
- package/templates/nextblock-template/hooks/use-hotkeys.ts +27 -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/tsconfig.json +3 -0
- 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
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
<
|
|
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 {
|
|
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
|
-
<
|
|
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
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
<
|
|
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 ?
|
|
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
|
+
}
|