@nextbridgehq/payload-block-builder 0.1.6 → 0.1.8

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/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Payload Block Builder
2
2
 
3
+ Developed and open-sourced by [Nextbridge](https://nextbridge.com). This plugin was built to solve a real problem we kept running into: content editors needing to manage flexible page layouts without requiring a developer for every change.
4
+
5
+ ---
3
6
 
4
7
  A visual block builder plugin for Payload v3. Design your content blocks through a drag-and-drop UI, store the schemas in your database, and let editors build pages without waiting on a developer every time something needs to change.
5
8
 
@@ -11,15 +14,64 @@ A visual block builder plugin for Payload v3. Design your content blocks through
11
14
  - **Evolving content schemas:** Roll out new block versions without breaking content that was built against older ones.
12
15
  - **Headless frontends:** Fetch structured block data from the Payload API and render it with any framework.
13
16
 
17
+ ## Database compatibility
18
+
19
+ Works with all Payload-supported databases — no direct SQL, no database-specific code:
20
+
21
+ | Database | Adapter |
22
+ |---|---|
23
+ | PostgreSQL / Supabase / Neon | `@payloadcms/db-postgres` |
24
+ | SQLite / Turso / LibSQL | `@payloadcms/db-sqlite` |
25
+ | MongoDB | `@payloadcms/db-mongodb` |
26
+
27
+ ---
28
+
14
29
  ## Quick start
15
30
 
16
- 1. Install the plugin:
31
+ ### Option A — Automatic setup (recommended)
32
+
33
+ Install the package and run the init command from your project root:
34
+
35
+ ```bash
36
+ pnpm add @nextbridgehq/payload-block-builder
37
+ # or: npm install @nextbridgehq/payload-block-builder
38
+
39
+ npx payload-block-builder init
40
+ ```
41
+
42
+ The init command automatically:
43
+
44
+ - Creates `src/app/block-builder/page.tsx` — the builder UI page
45
+ - Creates `src/app/block-builder/layout.tsx` — standalone layout with `<html>` and `<body>` tags
46
+ - Updates `src/app/(payload)/custom.scss` — injects admin field styles
47
+ - Updates `payload.config.ts` — adds the `dynamicBlocksPlugin` import and config
48
+
49
+ Then regenerate the import map and start your dev server:
17
50
 
18
51
  ```bash
19
- npm install @nextbridgehq/payload-block-builder
52
+ pnpm generate:importmap
53
+ pnpm dev
20
54
  ```
21
55
 
22
- 2. Add the plugin to your `payload.config.ts`:
56
+ Visit `http://localhost:3000/block-builder` and you're ready to build.
57
+
58
+ > **PostgreSQL users:** Payload will automatically push the new schema tables on first startup in dev mode. If you are using migrations in production, run:
59
+ > ```bash
60
+ > pnpm payload migrate:create --name=add_block_builder
61
+ > pnpm payload migrate
62
+ > ```
63
+
64
+ ---
65
+
66
+ ### Option B — Manual setup
67
+
68
+ **1. Install:**
69
+
70
+ ```bash
71
+ pnpm add @nextbridgehq/payload-block-builder
72
+ ```
73
+
74
+ **2. Add the plugin to `payload.config.ts`:**
23
75
 
24
76
  ```ts
25
77
  import { dynamicBlocksPlugin } from '@nextbridgehq/payload-block-builder'
@@ -27,36 +79,85 @@ import { dynamicBlocksPlugin } from '@nextbridgehq/payload-block-builder'
27
79
  export default buildConfig({
28
80
  plugins: [
29
81
  dynamicBlocksPlugin({
30
- collections: ['pages', 'posts'],
82
+ collections: ['pages'],
31
83
  }),
32
84
  ],
33
85
  })
34
86
  ```
35
87
 
36
- 3. Create a page in your Next.js app to host the builder UI:
88
+ **3. Create `src/app/block-builder/layout.tsx`:**
89
+
90
+ ```tsx
91
+ import React from 'react'
92
+ import { headers } from 'next/headers'
93
+ import { redirect } from 'next/navigation'
94
+ import { getPayload } from 'payload'
95
+ import config from '@payload-config'
96
+ import '@nextbridgehq/payload-block-builder/builder.css'
97
+
98
+ export const metadata = { title: 'Block Builder' }
99
+
100
+ export default async function BlockBuilderLayout({ children }: { children: React.ReactNode }) {
101
+ const payload = await getPayload({ config })
102
+ const { user } = await payload.auth({ headers: await headers() })
103
+ if (!user) redirect('/admin/login')
104
+
105
+ return (
106
+ <html lang="en">
107
+ <body style={{ margin: 0, padding: 0, height: '100vh', overflow: 'hidden' }}>
108
+ {children}
109
+ </body>
110
+ </html>
111
+ )
112
+ }
113
+ ```
114
+
115
+ **4. Create `src/app/block-builder/page.tsx`:**
37
116
 
38
117
  ```tsx
39
- // app/block-builder/page.tsx
40
118
  'use client'
41
119
 
42
120
  import { BuilderShell } from '@nextbridgehq/payload-block-builder/client'
43
- import '@nextbridgehq/payload-block-builder/builder.css'
44
121
 
45
122
  export default function BlockBuilderPage() {
46
123
  return <BuilderShell />
47
124
  }
48
125
  ```
49
126
 
50
- 4. If you're on a SQL database, run the migration:
127
+ **5. Add admin field styles to `src/app/(payload)/custom.scss`:**
128
+
129
+ ```scss
130
+ @import '@nextbridgehq/payload-block-builder/block-data-field.css';
131
+ @import '@nextbridgehq/payload-block-builder/schema-builder-field.css';
132
+ ```
133
+
134
+ **6. Regenerate the import map and start the dev server:**
51
135
 
52
136
  ```bash
53
- npx payload migrate:create
54
- npx payload migrate:run
137
+ pnpm generate:importmap
138
+ pnpm dev
55
139
  ```
56
140
 
57
- MongoDB users can skip this step.
141
+ ---
142
+
143
+ ## Plugin options
58
144
 
59
- Visit `/block-builder` in your browser and you're in.
145
+ ```ts
146
+ dynamicBlocksPlugin({
147
+ enabled?: boolean // Disable without removing. Default: true
148
+ collections?: string[] // Collection slugs that get the DB Layout tab. Default: []
149
+ fieldName?: string // Name of the layout array field. Default: 'dbLayout'
150
+ tabLabel?: string // Label shown on the tab in the admin UI. Default: 'DB Layout'
151
+ })
152
+ ```
153
+
154
+ The `--collections` flag is also supported in the init command:
155
+
156
+ ```bash
157
+ npx payload-block-builder init --collections=pages,posts
158
+ ```
159
+
160
+ ---
60
161
 
61
162
  ## Usage
62
163
 
@@ -66,16 +167,16 @@ Visit `/block-builder` in your browser and you're in.
66
167
  2. Click "Add Block" and give it a name and slug.
67
168
  3. Drag fields from the panel on the right onto the canvas.
68
169
  4. Configure each field (label, name, required, options, etc.).
69
- 5. Hit Publish. The block is saved to your database and a version snapshot is created.
170
+ 5. Click Publish. The block schema is saved to your database and a version snapshot is created.
70
171
 
71
172
  ### Using blocks in a collection
72
173
 
73
- Any collection you listed in `collections` gets a new "DB Layout" tab in the Payload admin. Editors can:
174
+ Any collection listed in the `collections` option gets a new "DB Layout" tab in the Payload admin. Editors can:
74
175
 
75
- 1. Click "Add Row" to add a block.
176
+ 1. Click "Add Row" to add a block instance.
76
177
  2. Select a block definition and the version of its schema to use.
77
- 3. Fill in the fields. They render dynamically based on the selected schema.
78
- 4. Reorder, hide, or add anchor IDs to individual blocks.
178
+ 3. Fill in the fields they render dynamically based on the selected schema.
179
+ 4. Reorder, hide, or add anchor IDs to individual block instances.
79
180
  5. Save the document as normal.
80
181
 
81
182
  ### Reading block data on the frontend
@@ -91,29 +192,74 @@ for (const block of page.dbLayout) {
91
192
  }
92
193
  ```
93
194
 
94
- From there it's just a switch or a component map. Render each block type however you like.
195
+ Render each block type however you like — a switch statement or a component map both work well.
196
+
197
+ ---
198
+
199
+ ## How it works
200
+
201
+ - **Block definitions** are stored in a `block-definitions` collection. Each document is a named block type with a slug and a list of field definitions.
202
+ - **Versions** are stored in a `block-definition-versions` collection. Every time you publish a block, a snapshot of its schema is saved as a new version.
203
+ - **Documents** in opted-in collections store a reference to the exact block version they were built against, so updating a block schema later does not break existing content.
204
+ - **The DB Layout tab** is injected automatically into each collection you list. It renders a dynamic array field where editors pick a block and version, and the field UI adjusts to match.
205
+ - **Four internal API endpoints** power the builder UI and the admin field components. You do not need to call them directly.
206
+
207
+ ---
208
+
209
+ ## Supported field types
210
+
211
+ These field types are available in the block builder and render correctly in the admin field UI:
212
+
213
+ | Type | Description |
214
+ |---|---|
215
+ | `text` | Single-line text input |
216
+ | `textarea` | Multi-line text input |
217
+ | `number` | Numeric input |
218
+ | `email` | Email address |
219
+ | `date` | Date picker |
220
+ | `checkbox` | Boolean toggle |
221
+ | `select` | Dropdown with custom options |
222
+ | `radio` | Radio button group with custom options |
223
+ | `upload` | File / image picker (from the media collection) |
224
+ | `relationship` | Document picker from any collection |
225
+ | `json` | Raw JSON data |
226
+
227
+ ---
228
+
229
+ ## Using `dbLayoutField` directly
95
230
 
96
- ## Options
231
+ If you prefer not to use the plugin's `collections` option, you can add the layout tab manually to any collection:
97
232
 
98
233
  ```ts
99
- dynamicBlocksPlugin({
100
- enabled?: boolean // disable without removing. Default: true
101
- collections?: string[] // which collection slugs get the DB Layout tab. Default: []
102
- fieldName?: string // name of the layout array field. Default: 'dbLayout'
103
- tabLabel?: string // label shown on the tab in the admin. Default: 'DB Layout'
104
- })
234
+ import { dbLayoutField } from '@nextbridgehq/payload-block-builder'
235
+
236
+ export const Pages: CollectionConfig = {
237
+ slug: 'pages',
238
+ fields: [
239
+ {
240
+ type: 'tabs',
241
+ tabs: [
242
+ { label: 'Content', fields: [] },
243
+ dbLayoutField(), // fieldName='dbLayout', tab label='DB Layout'
244
+ dbLayoutField('heroBlocks', 'Hero'), // custom field name and tab label
245
+ ],
246
+ },
247
+ ],
248
+ }
105
249
  ```
106
250
 
107
- ## How it works
251
+ ---
252
+
253
+ ## CSS imports reference
254
+
255
+ | Import path | Purpose |
256
+ |---|---|
257
+ | `@nextbridgehq/payload-block-builder/builder.css` | Block Builder UI page styles |
258
+ | `@nextbridgehq/payload-block-builder/block-data-field.css` | DB Layout field styles in admin |
259
+ | `@nextbridgehq/payload-block-builder/schema-builder-field.css` | Schema Builder field styles in admin |
108
260
 
109
- - **Block definitions** are stored in a `block-definitions` collection. Each document is a named block type with a slug, labels, and a list of field definitions.
110
- - **Versions** are stored in a `block-definition-versions` collection. Every time you publish a block in the builder, a snapshot of its current schema is saved as a new version.
111
- - **Documents** in your opted-in collections store a reference to the exact block version they were built against, so updating a block's schema later won't break existing content.
112
- - **The DB Layout tab** is injected automatically on each collection you list. It renders a dynamic array field where editors pick a block and version, and the field data UI adjusts to match the selected schema.
113
- - **Four internal API endpoints** power the builder UI and the `BlockDataField` admin component. You don't need to call them yourself.
261
+ ---
114
262
 
115
- ## Requirements
263
+ ## License
116
264
 
117
- - Payload v3
118
- - Next.js 14+
119
- - Any Payload-supported database (PostgreSQL, MongoDB, SQLite)
265
+ MIT © [Nextbridge](https://nextbridge.com)
package/dist/bin/init.js CHANGED
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- #!/usr/bin/env node
3
2
 
4
3
  // src/bin/init.ts
5
4
  import fs from "fs";
@@ -13,13 +12,21 @@ export default function BlockBuilderPage() {
13
12
  }
14
13
  `;
15
14
  var LAYOUT_CONTENT = `import React from 'react'
15
+ import { headers } from 'next/headers'
16
+ import { redirect } from 'next/navigation'
17
+ import { getPayload } from 'payload'
18
+ import config from '@payload-config'
16
19
  import '@nextbridgehq/payload-block-builder/builder.css'
17
20
 
18
21
  export const metadata = {
19
22
  title: 'Block Builder',
20
23
  }
21
24
 
22
- export default function BlockBuilderLayout({ children }: { children: React.ReactNode }) {
25
+ export default async function BlockBuilderLayout({ children }: { children: React.ReactNode }) {
26
+ const payload = await getPayload({ config })
27
+ const { user } = await payload.auth({ headers: await headers() })
28
+ if (!user) redirect('/admin/login')
29
+
23
30
  return (
24
31
  <html lang="en">
25
32
  <body style={{ margin: 0, padding: 0, height: '100vh', overflow: 'hidden' }}>
@@ -76,7 +83,7 @@ function addImport(content) {
76
83
  while ((m = lastFromRegex.exec(content)) !== null) lastMatch = m;
77
84
  if (!lastMatch) return newImport + "\n" + content;
78
85
  const insertPos = lastMatch.index + lastMatch[0].length;
79
- return content.slice(0, insertPos) + "\n" + newImport + content.slice(insertPos);
86
+ return content.slice(0, insertPos) + "\n" + newImport + "\n" + content.slice(insertPos);
80
87
  }
81
88
  function insertIntoPluginsArray(content, collectionsArg) {
82
89
  const pluginsMatch = /\bplugins\s*:\s*\[/.exec(content);
@@ -84,13 +91,19 @@ function insertIntoPluginsArray(content, collectionsArg) {
84
91
  const openPos = content.indexOf("[", pluginsMatch.index);
85
92
  const closePos = findClosingBracket(content, openPos);
86
93
  if (closePos === -1) return null;
94
+ const beforePlugins = content.slice(0, pluginsMatch.index);
95
+ const pluginsLineStart = beforePlugins.lastIndexOf("\n") + 1;
96
+ const outerIndent = content.slice(pluginsLineStart, pluginsMatch.index).match(/^([ \t]*)/)?.[1] ?? " ";
97
+ const entryIndent = outerIndent + " ";
87
98
  const beforeClose = content.slice(0, closePos);
88
99
  const prevNL = beforeClose.lastIndexOf("\n");
89
- const closingIndent = beforeClose.slice(prevNL + 1).match(/^([ \t]*)/)?.[1] ?? " ";
90
- const entryIndent = closingIndent + " ";
91
- const newLine = `${entryIndent}dynamicBlocksPlugin({ collections: [${collectionsArg}] }),
92
- `;
93
- return content.slice(0, prevNL + 1) + newLine + content.slice(prevNL + 1);
100
+ const isSingleLine = prevNL < openPos;
101
+ const newEntry = `${entryIndent}dynamicBlocksPlugin({ collections: [${collectionsArg}] }),`;
102
+ if (isSingleLine) {
103
+ return content.slice(0, openPos + 1) + "\n" + newEntry + "\n" + outerIndent + content.slice(closePos);
104
+ } else {
105
+ return content.slice(0, prevNL + 1) + newEntry + "\n" + content.slice(prevNL + 1);
106
+ }
94
107
  }
95
108
  function injectPluginsBlock(content, collectionsArg) {
96
109
  const collMatch = /\bcollections\s*:\s*\[/.exec(content);
@@ -110,7 +123,13 @@ ${outerIndent}],`;
110
123
  return content.slice(0, afterCollLine) + "\n" + pluginsBlock + content.slice(afterCollLine);
111
124
  }
112
125
  function modifyPayloadConfig(configPath, collectionsArg) {
113
- let content = fs.readFileSync(configPath, "utf8");
126
+ let content;
127
+ try {
128
+ content = fs.readFileSync(configPath, "utf8");
129
+ } catch (err) {
130
+ console.error(`Error: Could not read ${configPath}: ${err.message}`);
131
+ process.exit(1);
132
+ }
114
133
  if (content.includes("dynamicBlocksPlugin")) {
115
134
  console.log(`Skipped: dynamicBlocksPlugin already present in ${configPath}`);
116
135
  return;
@@ -119,8 +138,16 @@ function modifyPayloadConfig(configPath, collectionsArg) {
119
138
  const noComments = content.replace(/\/\/[^\n]*/g, "");
120
139
  const hasPluginsArray = /\bplugins\s*:\s*\[/.test(noComments);
121
140
  const hasPluginsShorthand = /^\s*plugins\s*,/m.test(noComments);
141
+ function writeConfig(data) {
142
+ try {
143
+ fs.writeFileSync(configPath, data, "utf8");
144
+ } catch (err) {
145
+ console.error(`Error: Could not write ${configPath}: ${err.message}`);
146
+ process.exit(1);
147
+ }
148
+ }
122
149
  if (hasPluginsShorthand && !hasPluginsArray) {
123
- fs.writeFileSync(configPath, content, "utf8");
150
+ writeConfig(content);
124
151
  console.log(`Updated: ${configPath} (added import)`);
125
152
  console.log(` Note: 'plugins' is imported from another file.`);
126
153
  console.log(` Add dynamicBlocksPlugin({ collections: ['pages'] }) to that file manually.`);
@@ -133,13 +160,13 @@ function modifyPayloadConfig(configPath, collectionsArg) {
133
160
  result = injectPluginsBlock(content, collectionsArg);
134
161
  }
135
162
  if (result === null) {
136
- fs.writeFileSync(configPath, content, "utf8");
163
+ writeConfig(content);
137
164
  console.log(`Updated: ${configPath} (added import only)`);
138
165
  console.log(` Could not auto-detect plugins array. Add manually:`);
139
166
  console.log(` plugins: [ dynamicBlocksPlugin({ collections: [${collectionsArg}] }) ]`);
140
167
  return;
141
168
  }
142
- fs.writeFileSync(configPath, result, "utf8");
169
+ writeConfig(result);
143
170
  console.log(`Updated: ${configPath} (added dynamicBlocksPlugin)`);
144
171
  }
145
172
  function printNextSteps(dbAdapter) {
@@ -165,7 +192,14 @@ function printNextSteps(dbAdapter) {
165
192
  function main() {
166
193
  const args = process.argv.slice(2);
167
194
  const collectionsFlag = args.find((a) => a.startsWith("--collections="));
168
- const collectionsValue = collectionsFlag ? collectionsFlag.replace("--collections=", "").split(",").map((s) => s.trim()) : ["pages"];
195
+ const rawCollections = collectionsFlag ? collectionsFlag.replace("--collections=", "").split(",").map((s) => s.trim()) : ["pages"];
196
+ const invalidSlugs = rawCollections.filter((c) => !/^[a-z0-9_-]+$/i.test(c));
197
+ if (invalidSlugs.length > 0) {
198
+ console.error(`Error: Invalid collection slug(s): ${invalidSlugs.join(", ")}`);
199
+ console.error("Collection slugs may only contain letters, numbers, hyphens, and underscores.");
200
+ process.exit(1);
201
+ }
202
+ const collectionsValue = rawCollections;
169
203
  const collectionsArg = collectionsValue.map((c) => `'${c}'`).join(", ");
170
204
  const appDir = findAppDir();
171
205
  if (!appDir) {
@@ -173,32 +207,52 @@ function main() {
173
207
  process.exit(1);
174
208
  }
175
209
  const builderDir = path.join(appDir, "block-builder");
176
- if (!fs.existsSync(builderDir)) {
177
- fs.mkdirSync(builderDir, { recursive: true });
210
+ try {
211
+ if (!fs.existsSync(builderDir)) {
212
+ fs.mkdirSync(builderDir, { recursive: true });
213
+ }
214
+ } catch (err) {
215
+ console.error(`Error: Could not create directory ${builderDir}: ${err.message}`);
216
+ process.exit(1);
178
217
  }
179
218
  const pagePath = path.join(builderDir, "page.tsx");
180
219
  const layoutPath = path.join(builderDir, "layout.tsx");
181
220
  if (fs.existsSync(pagePath)) {
182
221
  console.log(`Skipped: ${pagePath} already exists`);
183
222
  } else {
184
- fs.writeFileSync(pagePath, PAGE_CONTENT);
185
- console.log(`Created: ${pagePath}`);
223
+ try {
224
+ fs.writeFileSync(pagePath, PAGE_CONTENT);
225
+ console.log(`Created: ${pagePath}`);
226
+ } catch (err) {
227
+ console.error(`Error: Could not write ${pagePath}: ${err.message}`);
228
+ process.exit(1);
229
+ }
186
230
  }
187
231
  if (fs.existsSync(layoutPath)) {
188
232
  console.log(`Skipped: ${layoutPath} already exists`);
189
233
  } else {
190
- fs.writeFileSync(layoutPath, LAYOUT_CONTENT);
191
- console.log(`Created: ${layoutPath}`);
234
+ try {
235
+ fs.writeFileSync(layoutPath, LAYOUT_CONTENT);
236
+ console.log(`Created: ${layoutPath}`);
237
+ } catch (err) {
238
+ console.error(`Error: Could not write ${layoutPath}: ${err.message}`);
239
+ process.exit(1);
240
+ }
192
241
  }
193
242
  const payloadRouteDir = path.join(appDir, "(payload)");
194
243
  const customScssPath = path.join(payloadRouteDir, "custom.scss");
195
244
  if (fs.existsSync(customScssPath)) {
196
- const existing = fs.readFileSync(customScssPath, "utf8");
197
- if (!existing.includes("@nextbridgehq/payload-block-builder")) {
198
- fs.appendFileSync(customScssPath, "\n" + CUSTOM_SCSS_IMPORTS);
199
- console.log(`Updated: ${customScssPath} (added admin field styles)`);
200
- } else {
201
- console.log(`Skipped: ${customScssPath} already has block-builder imports`);
245
+ try {
246
+ const existing = fs.readFileSync(customScssPath, "utf8");
247
+ if (!existing.includes("@nextbridgehq/payload-block-builder")) {
248
+ fs.appendFileSync(customScssPath, "\n" + CUSTOM_SCSS_IMPORTS);
249
+ console.log(`Updated: ${customScssPath} (added admin field styles)`);
250
+ } else {
251
+ console.log(`Skipped: ${customScssPath} already has block-builder imports`);
252
+ }
253
+ } catch (err) {
254
+ console.error(`Error: Could not update ${customScssPath}: ${err.message}`);
255
+ process.exit(1);
202
256
  }
203
257
  }
204
258
  const configPath = findPayloadConfig();