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:

  1. Seed: Default blocks created in api/prisma/seed.ts
  2. Fetch: Editor loads all blocks via GET /api/page-blocks
  3. Register: GrapesJSEditor registers each block with BlockManager
  4. Render: Blocks appear in left panel (grouped by category)
  5. 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 name
  • category (string?) — Group name (default: null)
  • sortOrder (number?, default: 0) — Position in list
  • schema (JSON, required) — Property definitions
  • defaults (JSON, required) — Default values matching schema
  • thumbnail (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 exist
  • 400 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

  1. Open Editor: Admin → Pages → Click "Edit" on any page
  2. Locate Block: Left panel → Expand "Headers" category
  3. Drag Block: Drag "Hero Section" to canvas
  4. 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"
  5. 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 generateBlockHtml update)

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:

  1. GrapesJSEditor not re-fetching blocks
  2. generateBlockHtml() missing case
  3. Category name mismatch

Solutions:

  1. Reload editor:

    • Close page editor → Re-open
    • Blocks fetched on mount
  2. Add HTML generation case:

    case 'my-new-block':
      return `<section>My block HTML</section>`;
    
  3. Check category:

    SELECT category FROM page_blocks WHERE type = 'my-new-block';
    -- Category should match GrapesJS panel (case-sensitive)
    
  4. 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:

  1. Defaults not matching schema keys
  2. HTML template ignores defaults
  3. Type mismatch (string vs number)

Solutions:

  1. Verify defaults match schema:

    // Schema
    { "title": { "type": "string" } }
    
    // Defaults (good)
    { "title": "Welcome" }
    
    // Defaults (bad - key mismatch)
    { "heading": "Welcome" }
    
  2. Check HTML template:

    // Good - uses defaults
    return `<h1>${defaults.title || 'Fallback'}</h1>`;
    
    // Bad - ignores defaults
    return `<h1>Hardcoded Title</h1>`;
    
  3. 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:

  1. generateBlockHtml() returns invalid HTML
  2. Inline styles have syntax errors
  3. Missing closing tags

Solutions:

  1. Validate HTML:

    const html = generateBlockHtml('my-block', defaults);
    console.log(html); // Check for malformed tags
    
  2. Test inline styles:

    <!-- Bad - missing quotes -->
    <div style=padding: 20px>
    
    <!-- Good - quoted attribute -->
    <div style="padding: 20px;">
    
  3. 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:

  1. Category filtering:

    • Only fetch blocks for specific category
    • Lazy-load categories on expand
  2. Pagination:

    • Load first 20 blocks, fetch more on scroll
    • Not implemented in current version
  3. 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>


Frontend Components

Backend Modules

Database

Features

Seed Data