Block Library¶
Reusable page component system with JSON schema definitions, default values, and campaign-specific customization.
Overview¶
The Block Library provides a database-driven system for managing reusable page components (blocks) in the GrapesJS editor. Administrators can use pre-configured blocks or create custom ones tailored to their campaign needs.
Key Features¶
- Database-Driven: Blocks stored in PostgreSQL (PageBlock model)
- JSON Schema: Define configurable properties for each block type
- Default Values: Pre-populate blocks with campaign-specific content
- Category Organization: Group blocks (Headers, Content, Actions, etc.)
- Sort Order: Control block position in editor panel
- 6 Default Blocks: Hero, Text, Features, CTA, Testimonials, Contact Form
- Custom Blocks: Create campaign-specific blocks via admin API
Architecture¶
graph LR
A[(PageBlock Table)] -->|GET /api/page-blocks| B[API Service]
B --> C[LandingPageEditor]
C --> D[GrapesJSEditor]
D --> E[BlockManager]
E --> F[Left Panel]
G[Admin] -->|POST /api/page-blocks| B
G -->|Define Schema| H[JSON Schema]
G -->|Set Defaults| I[Default Values]
H --> A
I --> A
style A fill:#3498db
style E fill:#9d4edd
style F fill:#2ecc71
Flow:
- Seed: Default blocks created in
api/prisma/seed.ts - Fetch: Editor loads all blocks via
GET /api/page-blocks - Register: GrapesJSEditor registers each block with BlockManager
- Render: Blocks appear in left panel (grouped by category)
- Customize: Admin creates custom blocks via API (future enhancement)
Database Model¶
PageBlock Table¶
Schema:
model PageBlock {
id String @id @default(uuid())
type String @unique // Block type identifier (e.g., 'hero', 'text')
label String // Display name in editor ("Hero Section")
category String? // Group blocks ("Headers", "Content", "Actions")
sortOrder Int @default(0) // Position in left panel
schema Json // JSON schema for configurable properties
defaults Json // Default values for schema fields
thumbnail String? // Preview image URL (future enhancement)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category, sortOrder])
}
Fields:
| Field | Type | Description |
|---|---|---|
id |
String (UUID) | Primary key |
type |
String | Unique identifier (e.g., "hero", "features") |
label |
String | Human-readable name shown in editor |
category |
String? | Group blocks in collapsible sections |
sortOrder |
Int | Order within category (lower = higher in list) |
schema |
JSON | Property definitions (field name, type, label) |
defaults |
JSON | Default values for each schema field |
thumbnail |
String? | Preview image URL (not implemented) |
Indexes:
type(unique)category + sortOrder(composite, for sorted listing)
Default Blocks¶
1. Hero Section¶
Type: hero
Category: Headers
Schema:
{
"title": { "type": "string", "label": "Title" },
"subtitle": { "type": "string", "label": "Subtitle" },
"backgroundImage": { "type": "string", "label": "Background Image URL" },
"ctaText": { "type": "string", "label": "Button Text" },
"ctaUrl": { "type": "string", "label": "Button URL" }
}
Defaults:
{
"title": "Welcome to Our Campaign",
"subtitle": "Join us in making a difference in your community.",
"backgroundImage": "",
"ctaText": "Get Involved",
"ctaUrl": "#"
}
Rendered HTML:
<section style="padding: 80px 40px; text-align: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: #fff;">
<h1 style="font-size: 2.5rem; margin-bottom: 16px;">Welcome to Our Campaign</h1>
<p style="font-size: 1.25rem; opacity: 0.85; margin-bottom: 32px;">Join us in making a difference in your community.</p>
<a href="#" style="display: inline-block; padding: 12px 32px; background: #9d4edd; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600;">Get Involved</a>
</section>
2. Text Block¶
Type: text
Category: Content
Schema:
{
"heading": { "type": "string", "label": "Heading" },
"body": { "type": "text", "label": "Body Text" }
}
Defaults:
{
"heading": "About Us",
"body": "Tell your story here. Explain your mission, values, and what drives your campaign forward."
}
Rendered HTML:
<section style="padding: 60px 40px; max-width: 800px; margin: 0 auto;">
<h2 style="font-size: 1.75rem; margin-bottom: 16px;">About Us</h2>
<p style="font-size: 1rem; line-height: 1.7; opacity: 0.85;">Tell your story here. Explain your mission, values, and what drives your campaign forward.</p>
</section>
3. Features Grid¶
Type: features
Category: Content
Schema:
{
"features": {
"type": "array",
"label": "Features",
"items": {
"title": "string",
"description": "string",
"icon": "string"
}
}
}
Defaults:
{
"features": [
{ "title": "Community Action", "description": "Organize local events and initiatives.", "icon": "" },
{ "title": "Advocacy", "description": "Email your representatives directly.", "icon": "" },
{ "title": "Volunteer", "description": "Sign up for shifts and make a difference.", "icon": "" }
]
}
Rendered HTML:
<section style="padding: 60px 40px;">
<div style="display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;">
<div style="flex: 1; min-width: 250px; padding: 24px; text-align: center;">
<h3 style="font-size: 1.25rem; margin-bottom: 8px;">Community Action</h3>
<p style="opacity: 0.8;">Organize local events and initiatives.</p>
</div>
<div style="flex: 1; min-width: 250px; padding: 24px; text-align: center;">
<h3 style="font-size: 1.25rem; margin-bottom: 8px;">Advocacy</h3>
<p style="opacity: 0.8;">Email your representatives directly.</p>
</div>
<div style="flex: 1; min-width: 250px; padding: 24px; text-align: center;">
<h3 style="font-size: 1.25rem; margin-bottom: 8px;">Volunteer</h3>
<p style="opacity: 0.8;">Sign up for shifts and make a difference.</p>
</div>
</div>
</section>
4. Call to Action¶
Type: cta
Category: Actions
Schema:
{
"heading": { "type": "string", "label": "Heading" },
"description": { "type": "string", "label": "Description" },
"buttonText": { "type": "string", "label": "Button Text" },
"buttonUrl": { "type": "string", "label": "Button URL" }
}
Defaults:
{
"heading": "Ready to Take Action?",
"description": "Join thousands of community members making their voices heard.",
"buttonText": "Join Now",
"buttonUrl": "#"
}
Rendered HTML:
<section style="padding: 60px 40px; text-align: center; background: linear-gradient(135deg, #9d4edd 0%, #7b2cbf 100%); color: #fff;">
<h2 style="font-size: 2rem; margin-bottom: 12px;">Ready to Take Action?</h2>
<p style="font-size: 1.1rem; margin-bottom: 24px; opacity: 0.9;">Join thousands of community members making their voices heard.</p>
<a href="#" style="display: inline-block; padding: 12px 32px; background: #fff; color: #9d4edd; text-decoration: none; border-radius: 6px; font-weight: 600;">Join Now</a>
</section>
5. Testimonials¶
Type: testimonials
Category: Content
Schema:
{
"quotes": {
"type": "array",
"label": "Quotes",
"items": {
"text": "string",
"author": "string",
"role": "string"
}
}
}
Defaults:
{
"quotes": [
{ "text": "This platform made it so easy to contact my representatives.", "author": "Jane D.", "role": "Community Member" },
{ "text": "I signed up for a volunteer shift and it changed my perspective.", "author": "Mark S.", "role": "Volunteer" }
]
}
Rendered HTML:
<section style="padding: 60px 40px;">
<div style="display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;">
<div style="flex: 1; min-width: 280px; padding: 24px; background: rgba(255,255,255,0.05); border-radius: 8px;">
<p style="font-style: italic; margin-bottom: 12px;">"This platform made it so easy to contact my representatives."</p>
<p style="font-weight: 600; margin-bottom: 2px;">Jane D.</p>
<p style="font-size: 0.85rem; opacity: 0.7;">Community Member</p>
</div>
<div style="flex: 1; min-width: 280px; padding: 24px; background: rgba(255,255,255,0.05); border-radius: 8px;">
<p style="font-style: italic; margin-bottom: 12px;">"I signed up for a volunteer shift and it changed my perspective."</p>
<p style="font-weight: 600; margin-bottom: 2px;">Mark S.</p>
<p style="font-size: 0.85rem; opacity: 0.7;">Volunteer</p>
</div>
</div>
</section>
6. Contact Form¶
Type: contact-form
Category: Actions
Schema:
{
"heading": { "type": "string", "label": "Heading" },
"fields": {
"type": "array",
"label": "Fields",
"items": {
"name": "string",
"type": "string",
"required": "boolean"
}
}
}
Defaults:
{
"heading": "Get in Touch",
"fields": [
{ "name": "name", "type": "text", "required": true },
{ "name": "email", "type": "email", "required": true },
{ "name": "message", "type": "textarea", "required": true }
]
}
Rendered HTML:
<section style="padding: 60px 40px; max-width: 600px; margin: 0 auto;">
<h2 style="text-align: center; margin-bottom: 24px;">Get in Touch</h2>
<form style="display: flex; flex-direction: column; gap: 16px;">
<input type="text" placeholder="Name" style="padding: 10px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.05); color: inherit;" />
<input type="email" placeholder="Email" style="padding: 10px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.05); color: inherit;" />
<textarea placeholder="Message" rows="4" style="padding: 10px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.05); color: inherit; resize: vertical;"></textarea>
<button type="submit" style="padding: 12px 24px; background: #9d4edd; color: #fff; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">Send Message</button>
</form>
</section>
Note: Form submission not wired (static HTML). Use grapesjs-plugin-forms for backend integration.
API Endpoints¶
Admin Routes¶
Prefix: /api/page-blocks
Authentication: Requires admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN)
List Blocks¶
Query Parameters:
category(string?) — Filter by category
Response:
[
{
"id": "default-hero",
"type": "hero",
"label": "Hero Section",
"category": "Headers",
"sortOrder": 1,
"schema": {
"title": { "type": "string", "label": "Title" },
"subtitle": { "type": "string", "label": "Subtitle" }
},
"defaults": {
"title": "Welcome to Our Campaign",
"subtitle": "Join us in making a difference."
},
"thumbnail": null,
"createdAt": "2026-01-10T00:00:00Z",
"updatedAt": "2026-01-10T00:00:00Z"
}
]
Sorting:
- Results ordered by
category ASC, sortOrder ASC - Blocks in same category appear in sortOrder sequence
Get Block¶
Response: Single PageBlock object
Errors:
404 BLOCK_NOT_FOUND— Block doesn't exist
Create Block¶
POST /api/page-blocks
Content-Type: application/json
{
"type": "campaign-stats",
"label": "Campaign Stats",
"category": "Campaign",
"sortOrder": 10,
"schema": {
"volunteers": { "type": "number", "label": "Volunteers" },
"emails": { "type": "number", "label": "Emails Sent" }
},
"defaults": {
"volunteers": 1250,
"emails": 5400
}
}
Request Body:
type(string, required) — Unique type identifier (alphanumeric + hyphens)label(string, required) — Display namecategory(string?) — Group name (default:null)sortOrder(number?, default: 0) — Position in listschema(JSON, required) — Property definitionsdefaults(JSON, required) — Default values matching schemathumbnail(string?) — Preview image URL
Response: Created PageBlock object (201 status)
Errors:
400 VALIDATION_ERROR— Invalid schema or type collision
Update Block¶
PUT /api/page-blocks/:id
Content-Type: application/json
{
"label": "Updated Label",
"defaults": {
"volunteers": 2000
}
}
Request Body: (all fields optional except constraints)
type(string?) — Cannot change after creation (immutable)label(string?)category(string?)sortOrder(number?)schema(JSON?)defaults(JSON?)
Response: Updated PageBlock object
Errors:
404 BLOCK_NOT_FOUND— Block doesn't exist400 VALIDATION_ERROR— Invalid schema or defaults
Delete Block¶
Response: 204 No Content
Errors:
404 BLOCK_NOT_FOUND— Block doesn't exist
Side Effects:
- Pages using this block will still render (HTML is cached)
- Block removed from editor panel for new pages
Schema Format¶
Property Types¶
Supported Types:
| Type | Description | Example |
|---|---|---|
string |
Short text field | Title, subtitle, URL |
text |
Multi-line text | Body paragraph |
number |
Numeric value | Volunteer count, price |
boolean |
True/false toggle | Show/hide element |
array |
List of items | Features, testimonials |
Simple Property¶
Rendered in GrapesJS: Text input labeled "Title"
Array Property¶
{
"features": {
"type": "array",
"label": "Features",
"items": {
"title": "string",
"description": "string",
"icon": "string"
}
}
}
Rendered in GrapesJS:
- Repeatable item group
- Add/remove buttons
- Each item has 3 fields (title, description, icon)
Defaults Matching¶
Schema:
{
"heading": { "type": "string", "label": "Heading" },
"count": { "type": "number", "label": "Count" }
}
Valid Defaults:
Invalid Defaults:
{
"heading": 123, // Type mismatch (should be string)
"count": "foo" // Type mismatch (should be number)
}
Admin Workflow¶
Using Default Blocks¶
- Open Editor: Admin → Pages → Click "Edit" on any page
- Locate Block: Left panel → Expand "Headers" category
- Drag Block: Drag "Hero Section" to canvas
- Configure: Click block → Right panel shows properties
- Title:
"Join the Movement" - Subtitle:
"Together we can make a difference." - CTA Text:
"Sign Up" - CTA URL:
"/shifts" - Save: Press
Ctrl+S→ Block HTML stored in database
Creating Custom Blocks¶
Note: Custom block creation UI not implemented. Use API directly.
Example: Campaign Stats Block
curl -X POST http://localhost:4000/api/page-blocks \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "campaign-stats",
"label": "Campaign Stats",
"category": "Campaign",
"sortOrder": 10,
"schema": {
"volunteers": { "type": "number", "label": "Volunteers" },
"emails": { "type": "number", "label": "Emails Sent" },
"events": { "type": "number", "label": "Events" }
},
"defaults": {
"volunteers": 1250,
"emails": 5400,
"events": 32
}
}'
Result:
- New block appears in left panel under "Campaign" category
- Dragging block inserts HTML (requires
generateBlockHtmlupdate)
Updating Block Defaults¶
Use Case: Update hero CTA text for all new pages
curl -X PUT http://localhost:4000/api/page-blocks/default-hero \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"defaults": {
"title": "Welcome to Our 2026 Campaign",
"subtitle": "Join us in making a difference.",
"ctaText": "Get Started Today",
"ctaUrl": "/shifts"
}
}'
Effect:
- New pages using hero block get updated defaults
- Existing pages unchanged (HTML already rendered)
Code Examples¶
Fetching Blocks for Editor¶
import { api } from '@/lib/api';
import type { PageBlock } from '@/types/api';
async function loadBlocks(): Promise<PageBlock[]> {
const { data } = await api.get<PageBlock[]>('/page-blocks');
return data.sort((a, b) => {
// Sort by category, then sortOrder
const catCompare = (a.category || '').localeCompare(b.category || '');
return catCompare !== 0 ? catCompare : a.sortOrder - b.sortOrder;
});
}
Creating Custom Block¶
async function createCampaignStatsBlock() {
const { data } = await api.post<PageBlock>('/page-blocks', {
type: 'campaign-stats',
label: 'Campaign Stats',
category: 'Campaign',
sortOrder: 10,
schema: {
volunteers: { type: 'number', label: 'Volunteers' },
emails: { type: 'number', label: 'Emails Sent' },
events: { type: 'number', label: 'Events' },
},
defaults: {
volunteers: 1250,
emails: 5400,
events: 32,
},
});
console.log('Created block:', data.id);
return data;
}
Extending generateBlockHtml()¶
// In admin/src/components/GrapesJSEditor.tsx
function generateBlockHtml(type: string, defaults: Record<string, unknown>): string {
switch (type) {
// ... existing cases ...
case 'campaign-stats': {
const volunteers = defaults.volunteers || 0;
const emails = defaults.emails || 0;
const events = defaults.events || 0;
return `
<section style="padding: 60px 40px; background: #f8f9fa; text-align: center;">
<h2 style="margin-bottom: 32px; font-size: 2rem;">Our Impact</h2>
<div style="display: flex; gap: 48px; justify-content: center; flex-wrap: wrap;">
<div>
<div style="font-size: 3rem; font-weight: 700; color: #9d4edd;">${(volunteers as number).toLocaleString()}</div>
<div style="font-size: 1rem; color: #666;">Volunteers</div>
</div>
<div>
<div style="font-size: 3rem; font-weight: 700; color: #9d4edd;">${(emails as number).toLocaleString()}</div>
<div style="font-size: 1rem; color: #666;">Emails Sent</div>
</div>
<div>
<div style="font-size: 3rem; font-weight: 700; color: #9d4edd;">${events}</div>
<div style="font-size: 1rem; color: #666;">Events</div>
</div>
</div>
</section>`;
}
default:
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
}
}
Troubleshooting¶
Problem: Block Not Appearing in Editor¶
Symptoms:
- Created block via API
- Not visible in left panel
- Other blocks show correctly
Causes:
- GrapesJSEditor not re-fetching blocks
generateBlockHtml()missing case- Category name mismatch
Solutions:
- Reload editor:
- Close page editor → Re-open
-
Blocks fetched on mount
-
Add HTML generation case:
-
Check category:
-
Verify API response:
Problem: Default Values Not Applying¶
Symptoms:
- Drag block to canvas → Fields are empty
- Expected pre-filled title/subtitle
Causes:
- Defaults not matching schema keys
- HTML template ignores defaults
- Type mismatch (string vs number)
Solutions:
-
Verify defaults match schema:
-
Check HTML template:
-
Fix type mismatch:
Problem: Block HTML Not Rendering¶
Symptoms:
- Block appears in panel
- Dragging to canvas shows nothing or error
Causes:
generateBlockHtml()returns invalid HTML- Inline styles have syntax errors
- Missing closing tags
Solutions:
-
Validate HTML:
-
Test inline styles:
-
Use template literals carefully:
Performance Considerations¶
Block Count Impact¶
Threshold: 50+ blocks in library
Symptoms:
- Slow editor initialization (~1s+)
- Left panel laggy on scroll
Mitigations:
- Category filtering:
- Only fetch blocks for specific category
-
Lazy-load categories on expand
-
Pagination:
- Load first 20 blocks, fetch more on scroll
-
Not implemented in current version
-
Caching:
- Store blocks in localStorage
- Refresh only when version changes
Schema Complexity¶
Issue: Deeply nested array schemas (3+ levels) slow GrapesJS rendering
Example:
{
"sections": {
"type": "array",
"items": {
"features": {
"type": "array",
"items": {
"details": {
"type": "array"
}
}
}
}
}
}
Alternative: Flatten structure or use CODE mode
Security Considerations¶
Admin-Only Access¶
Protection: All /api/page-blocks endpoints require admin role
router.use(authenticate);
router.use(requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN));
Risk: Malicious admin creates XSS block with <script> tags
Mitigation:
- Accepted risk: Admins are trusted users
- Blocks only render on admin-authored pages (not user-submitted)
- Public pages use admin-created HTML (already trusted)
Type Validation¶
Attack: Submit block with type containing SQL injection
Protection:
// Zod schema in pages.schemas.ts
type: z.string()
.min(1)
.max(50)
.regex(/^[a-z0-9-]+$/, 'Type must be lowercase alphanumeric with hyphens'),
Safe types: hero, text-block, campaign-stats-2026
Rejected: '; DROP TABLE--, <script>alert(1)</script>
Related Documentation¶
Frontend Components¶
- GrapesJSEditor — Block registration logic
- LandingPageEditor — Fetches blocks for editor
Backend Modules¶
- blocks.routes — CRUD endpoints
- blocks.service — Business logic
- pages.schemas — Zod schemas
Database¶
- PageBlock Model — Schema + indexes
Features¶
- Page Builder — Landing page system
- GrapesJS Editor — Editor integration
Seed Data¶
- api/prisma/seed.ts — Default blocks definition