24 KiB
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
GET /api/page-blocks?category=Headers
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
GET /api/page-blocks/:id
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
DELETE /api/page-blocks/:id
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
{
"title": {
"type": "string",
"label": "Title"
}
}
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:
{
"heading": "Our Impact",
"count": 42
}
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"
- Title:
- 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:
case 'my-new-block': return `<section>My block HTML</section>`; -
Check category:
SELECT category FROM page_blocks WHERE type = 'my-new-block'; -- Category should match GrapesJS panel (case-sensitive) -
Verify API response:
curl -H "Authorization: Bearer $TOKEN" http://localhost:4000/api/page-blocks # Should include new block in 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:
// Schema { "title": { "type": "string" } } // Defaults (good) { "title": "Welcome" } // Defaults (bad - key mismatch) { "heading": "Welcome" } -
Check HTML template:
// Good - uses defaults return `<h1>${defaults.title || 'Fallback'}</h1>`; // Bad - ignores defaults return `<h1>Hardcoded Title</h1>`; -
Fix type mismatch:
// If schema says "number", defaults must be number { "count": { "type": "number" } } { "count": 42 } // Good { "count": "42" } // Bad
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:
const html = generateBlockHtml('my-block', defaults); console.log(html); // Check for malformed tags -
Test inline styles:
<!-- Bad - missing quotes --> <div style=padding: 20px> <!-- Good - quoted attribute --> <div style="padding: 20px;"> -
Use template literals carefully:
// Ensure all ${} expressions return strings return `<div>${defaults.title || ''}</div>`;
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