1164 lines
29 KiB
Markdown

# Page Builder
Complete WYSIWYG landing page builder with GrapesJS editor, slug-based public routing, and MkDocs Material theme integration.
---
## Overview
The Page Builder system provides a comprehensive solution for creating custom landing pages without coding. Administrators can use a visual drag-and-drop interface or write raw HTML/CSS directly.
### Key Features
- **Dual-Mode Editing**: Switch between VISUAL (GrapesJS drag-and-drop) and CODE (raw HTML editor)
- **Slug-Based Routing**: Public pages accessible at `/p/:slug` (e.g., `/p/about-us`)
- **MkDocs Export**: Publish pages to MkDocs documentation site with Material theme integration
- **SEO Meta Tags**: Configure title, description, and Open Graph images
- **Custom Blocks**: Reusable components (hero, features, CTA, testimonials, contact forms)
- **Video Integration**: Embed media library videos with standard or advanced players
- **Mobile Detection**: Editor warns users on small screens (desktop-only editing)
### Architecture Overview
```mermaid
graph LR
A[Admin] --> B[LandingPagesPage]
B --> C[Create Page Modal]
C --> D[LandingPageEditor]
D --> E[GrapesJS Editor]
E --> F[Save API]
F --> G[(LandingPage Model)]
G --> H[Public Route]
H --> I[/p/:slug]
D --> J[Publish Toggle]
J --> K[MkDocs Export]
K --> L[overrides/*.html]
K --> M[docs/*.md stub]
style E fill:#9d4edd
style G fill:#3498db
style K fill:#2ecc71
```
**Flow:**
1. Admin creates page via LandingPagesPage
2. Editor loads with GrapesJS (VISUAL mode) or Monaco (CODE mode)
3. Admin drags blocks, configures properties, saves (Ctrl+S)
4. API stores `projectData` (GrapesJS JSON), `htmlOutput`, `cssOutput`
5. On publish: API exports `.html` override + `.md` stub to MkDocs
6. Public users access page at `/p/:slug` (React route renders HTML)
---
## Database Models
### LandingPage
**Table:** `landing_pages`
**Key Fields:**
| Field | Type | Description |
|-------|------|-------------|
| `id` | String (UUID) | Primary key |
| `slug` | String | Unique URL-safe identifier (auto-generated from title) |
| `title` | String | Page title (internal + fallback SEO) |
| `description` | String? | Page description (internal) |
| `editorMode` | Enum | `VISUAL` (GrapesJS) or `CODE` (raw HTML) |
| `blocks` | JSON | GrapesJS `projectData` (components tree) |
| `htmlOutput` | String? | Rendered HTML (cached output from editor) |
| `cssOutput` | String? | Rendered CSS (cached output from editor) |
| `mkdocsPath` | String? | Override file path (e.g., `about.html`) |
| `mkdocsStubPath` | String? | Stub Markdown path (e.g., `about.md`) |
| `mkdocsExportMode` | Enum | `THEMED` (extends main.html) or `STANDALONE` (full HTML) |
| `mkdocsHideNav` | Boolean | Hide navigation sidebar in MkDocs |
| `mkdocsHideToc` | Boolean | Hide table of contents in MkDocs |
| `mkdocsSkipExport` | Boolean | Don't export to MkDocs (only accessible via /p/:slug) |
| `published` | Boolean | Public visibility (false = draft) |
| `seoTitle` | String? | Custom SEO title (overrides `title`) |
| `seoDescription` | String? | Meta description for search engines |
| `seoImage` | String? | Open Graph image URL |
| `createdAt` | DateTime | Creation timestamp |
| `updatedAt` | DateTime | Last modification timestamp |
**Indexes:**
- `slug` (unique)
- `published` (filter index)
**Relationships:**
- None (standalone model)
### PageBlock
See [Block Library](block-library.md) documentation.
---
## API Endpoints
### Admin Routes
**Prefix:** `/api/pages`
**Authentication:** Requires admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN)
#### List Pages
```http
GET /api/pages?page=1&limit=20&search=campaign&published=true
```
**Query Parameters:**
- `page` (number, default: 1) — Page number
- `limit` (number, default: 20, max: 100) — Results per page
- `search` (string?) — Search title, description, or slug (case-insensitive)
- `published` (string?) — Filter by status: `"true"`, `"false"`, or omit for all
**Response:**
```json
{
"pages": [
{
"id": "abc123",
"slug": "about-us",
"title": "About Our Campaign",
"description": "Learn more about our mission.",
"editorMode": "VISUAL",
"blocks": { /* GrapesJS JSON */ },
"htmlOutput": "<section>...</section>",
"cssOutput": "section { padding: 40px; }",
"mkdocsPath": "about.html",
"mkdocsStubPath": "about.md",
"mkdocsExportMode": "THEMED",
"mkdocsHideNav": false,
"mkdocsHideToc": true,
"mkdocsSkipExport": false,
"published": true,
"seoTitle": "About Us | Campaign 2026",
"seoDescription": "Join our movement for change.",
"seoImage": "https://example.com/og-image.jpg",
"createdAt": "2026-01-15T10:00:00Z",
"updatedAt": "2026-02-13T14:30:00Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 5,
"totalPages": 1
}
}
```
#### Get Page
```http
GET /api/pages/:id
```
**Response:** Single `LandingPage` object (same structure as list item above)
**Errors:**
- `404 PAGE_NOT_FOUND` — Page doesn't exist
#### Create Page
```http
POST /api/pages
Content-Type: application/json
{
"title": "New Landing Page",
"description": "Page description",
"editorMode": "VISUAL"
}
```
**Request Body:**
- `title` (string, required) — Page title (slug auto-generated)
- `description` (string?) — Internal description
- `editorMode` (enum?, default: `VISUAL`) — `VISUAL` or `CODE`
- `mkdocsPath` (string?) — Custom override path (defaults to `{slug}.html`)
**Response:** Created `LandingPage` object (201 status)
**Errors:**
- `400 INVALID_MKDOCS_PATH` — Invalid path (traversal attempt, missing .html extension)
**Behavior:**
- Slug auto-generated from title (lowercased, spaces→hyphens, alphanumeric only)
- Slug collision handling (appends `-2`, `-3`, etc.)
- `blocks` initialized as empty JSON object
- `published` defaults to `false`
#### Update Page
```http
PUT /api/pages/:id
Content-Type: application/json
{
"blocks": { /* GrapesJS projectData */ },
"htmlOutput": "<section>...</section>",
"cssOutput": "section { padding: 40px; }",
"published": true
}
```
**Request Body:** (all fields optional)
- `title` (string?) — New title (regenerates slug if changed)
- `description` (string?)
- `blocks` (JSON?) — GrapesJS `projectData`
- `htmlOutput` (string?) — Rendered HTML
- `cssOutput` (string?) — Rendered CSS
- `published` (boolean?) — Publish status
- `mkdocsPath` (string?) — Custom override path
- `mkdocsExportMode` (enum?) — `THEMED` or `STANDALONE`
- `mkdocsHideNav` (boolean?)
- `mkdocsHideToc` (boolean?)
- `mkdocsSkipExport` (boolean?)
- `seoTitle` (string?)
- `seoDescription` (string?)
- `seoImage` (string?)
**Response:** Updated `LandingPage` object
**Errors:**
- `404 PAGE_NOT_FOUND` — Page doesn't exist
- `400 INVALID_MKDOCS_PATH` — Invalid path
**Side Effects:**
- **On publish (published=true, mkdocsSkipExport=false):** Exports to MkDocs (writes `.html` + `.md` stub)
- **On unpublish or mkdocsSkipExport=true:** Removes MkDocs files
- **On title change:** Regenerates slug, updates `mkdocsPath` if it was auto-generated, cleans up old exports
#### Delete Page
```http
DELETE /api/pages/:id
```
**Response:** 204 No Content
**Errors:**
- `404 PAGE_NOT_FOUND` — Page doesn't exist
**Side Effects:**
- Removes MkDocs exports (`.html` override + `.md` stub) if they exist
#### Sync Overrides
```http
POST /api/pages/sync
```
**Purpose:** Import untracked `.html` files from `mkdocs/docs/overrides/` as CODE-mode pages. Useful for migrating hand-crafted HTML templates.
**Response:**
```json
{
"imported": 2,
"updated": 1,
"stubs": 3
}
```
**Behavior:**
1. Scans `mkdocs/docs/overrides/` recursively for `.html` files
2. For untracked files: Creates new CODE-mode page (published=true)
3. For tracked CODE-mode pages: Updates `htmlOutput` from disk (disk wins)
4. For tracked VISUAL-mode pages: Skips (managed by GrapesJS)
5. Backfills missing `.md` stubs for published pages
**Use Cases:**
- Migrate legacy hand-coded landing pages
- Import templates from designers
- Sync after manual file system edits
#### Validate Exports
```http
POST /api/pages/validate
```
**Purpose:** Verify MkDocs exports exist on disk, repair if missing.
**Response:**
```json
{
"validated": 10,
"repaired": 2,
"errors": [
{
"pageId": "xyz789",
"slug": "broken-page",
"error": "EACCES: permission denied"
}
]
}
```
**Behavior:**
1. Queries all published, non-skipped pages with `mkdocsPath`
2. Checks if `.html` override and `.md` stub exist
3. Re-exports if either missing
4. Updates `mkdocsStubPath` if changed
5. Returns error list for manual intervention
**Use Cases:**
- Recover from accidental file deletion
- Fix export state after container restarts
- Audit before MkDocs rebuild
### Public Routes
**Prefix:** `/api/pages`
**Authentication:** None (public access)
#### View Published Page
```http
GET /api/pages/:slug/view
```
**Example:**
```http
GET /api/pages/about-us/view
```
**Response:**
```json
{
"id": "abc123",
"slug": "about-us",
"title": "About Our Campaign",
"htmlOutput": "<section>...</section>",
"cssOutput": "section { padding: 40px; }",
"seoTitle": "About Us | Campaign 2026",
"seoDescription": "Join our movement for change.",
"seoImage": "https://example.com/og-image.jpg",
"createdAt": "2026-01-15T10:00:00Z",
"updatedAt": "2026-02-13T14:30:00Z"
}
```
**Errors:**
- `404 PAGE_NOT_FOUND` — Page doesn't exist or is unpublished
**Security:**
- Only returns published pages (`published=true`)
- Omits editor-only fields (`blocks`, `mkdocsPath`, etc.)
---
## Configuration
### Environment Variables
```bash
# MkDocs integration
MKDOCS_DOCS_PATH=/mkdocs/docs
# Override path: ${MKDOCS_DOCS_PATH}/overrides/
# Stub path: ${MKDOCS_DOCS_PATH}/ (root of docs)
```
**Docker Volume:**
```yaml
volumes:
- ./mkdocs:/mkdocs:rw
```
**Note:** API container needs write access to export files.
### Site Settings
**Feature Flag:** `ENABLE_LANDING_PAGES`
**Location:** Admin → Settings → Features → Landing Pages
**Default:** `true`
**Effect:** Shows/hides "Pages" menu item in admin sidebar
---
## Admin Workflow
### Creating a Page
1. **Navigate:** Admin sidebar → Pages
2. **Click:** "Create Page" button
3. **Fill form:**
- Title: `"About Us"` (slug auto-generated: `about-us`)
- Description: `"Learn about our campaign"` (optional)
- Editor Mode: `VISUAL` (default) or `CODE`
4. **Submit:** "Create & Edit" button
5. **Result:** Redirected to full-screen editor
### Visual Editing (VISUAL Mode)
1. **Editor opens:** GrapesJS interface with 3 panels:
- Left: Block library (drag-and-drop components)
- Center: Canvas (preview + inline editing)
- Right: Properties panel (configure selected component)
2. **Add blocks:** Drag "Hero Section" from left panel to canvas
3. **Configure:** Click hero → Edit title/subtitle/CTA in right panel
4. **Save:** Press `Ctrl+S` (or `Cmd+S` on Mac) → API saves `projectData`, `htmlOutput`, `cssOutput`
5. **Close:** Click "X" or "Back to Pages" → Returns to table
### Code Editing (CODE Mode)
1. **Editor opens:** Split-view Monaco editors:
- Left: HTML editor
- Right: CSS editor (optional)
2. **Edit HTML:** Write raw HTML with Jinja2 template syntax (for MkDocs)
3. **Save:** Press `Ctrl+S` → API saves `htmlOutput`, `cssOutput`
4. **Close:** Click "Back to Pages"
### Publishing a Page
**Option 1: From Table**
1. Locate page in table
2. Click "Publish" button in Actions column
3. Status tag changes: Draft → Published
4. Page accessible at `/p/{slug}`
**Option 2: From Settings Modal**
1. Click gear icon (Settings) in Actions column
2. Settings modal opens
3. (Field not shown in modal — use table toggle)
**Side Effects (on publish):**
- If `mkdocsSkipExport=false`: Exports `.html` + `.md` to MkDocs
- If `mkdocsSkipExport=true`: Only accessible via `/p/:slug` (no MkDocs export)
### Configuring SEO
1. Click gear icon (Settings) in Actions column
2. Fill SEO section:
- **SEO Title:** Custom title for `<title>` and Open Graph (defaults to `title`)
- **SEO Description:** Meta description for search engines
- **SEO Image:** Full URL to Open Graph image (e.g., `https://cdn.example.com/og.jpg`)
3. Click "Save"
4. Re-export to MkDocs if already published
### MkDocs Integration Settings
**Access:** Page Settings modal → MkDocs Integration section
**Fields:**
1. **Skip MkDocs Export** (checkbox)
- When enabled: Page NOT exported to MkDocs site
- Use case: Pages meant only for `/p/:slug` (not documentation)
- Default: `false` (export enabled)
2. **Override Path** (text input)
- Custom filename for override (e.g., `custom-about.html`)
- Default: Auto-generated from slug (`{slug}.html`)
- Validation: Must end with `.html`, no path traversal
3. **Full page MkDocs** (checkbox)
- When enabled: Exports as STANDALONE (full `<!DOCTYPE html>` document)
- When disabled: Exports as THEMED (wraps in `{% extends "main.html" %}`)
- Default: `false` (THEMED)
- Use case: Standalone pages with no MkDocs chrome (like `lander.html`)
4. **Hide navigation sidebar** (checkbox, only for THEMED mode)
- Adds `hide: [navigation]` to `.md` stub front matter
- Hides left sidebar on page
- Default: `false`
5. **Hide table of contents** (checkbox, only for THEMED mode)
- Adds `hide: [toc]` to `.md` stub front matter
- Hides right sidebar on page
- Default: `false`
**Workflow:**
1. Edit page settings
2. Configure MkDocs options
3. Save settings
4. If published: API auto-exports with new settings
5. Rebuild MkDocs: Admin → Pages → "Build Site" button
### Syncing Overrides
**Purpose:** Import hand-coded `.html` files from disk
**Workflow:**
1. Place `.html` files in `mkdocs/docs/overrides/` (on Docker host)
2. Admin → Pages → "Sync Overrides" button
3. API scans directory, imports new files as CODE-mode pages
4. Table refreshes, new pages appear
5. Edit pages normally, publish as needed
**Example:**
```bash
# On Docker host
echo '<h1>Custom Page</h1>' > mkdocs/docs/overrides/custom.html
# In admin panel
# Click "Sync Overrides" → 1 imported
```
### Validating Exports
**Purpose:** Verify MkDocs files exist, repair if missing
**Workflow:**
1. Admin → Pages → "Validate Exports" button
2. API checks all published pages:
- `.html` override exists?
- `.md` stub exists?
3. Re-exports if either missing
4. Shows result: `Validated 10 pages: 2 repaired`
**Use Cases:**
- After container restart (volume mount issues)
- After manual file deletion
- Before rebuilding MkDocs site
---
## Public Workflow
### Viewing a Published Page
1. **User navigates:** `https://yoursite.com/p/about-us`
2. **React router:** Matches `/p/:slug` route → Loads `LandingPage.tsx`
3. **API call:** `GET /api/pages/about-us/view`
4. **Response:** Returns `htmlOutput`, `cssOutput`, SEO fields
5. **Render:**
- Sets `document.title = seoTitle || title`
- Updates meta description, Open Graph image
- Injects `cssOutput` as `<style>` tag
- Renders `htmlOutput` via `dangerouslySetInnerHTML`
6. **Video hydration:** Scans for `.video-block` divs, replaces placeholders with React VideoPlayer components
### SEO Meta Tags
**Applied automatically on page load:**
```html
<html>
<head>
<title>About Us | Campaign 2026</title>
<meta name="description" content="Join our movement for change.">
<meta property="og:image" content="https://example.com/og-image.jpg">
</head>
<body>
<style>section { padding: 40px; }</style>
<section>...</section>
</body>
</html>
```
### Video Embedding
**Editor Placeholder:**
```html
<div class="video-block"
data-video-id="123"
data-player-type="advanced"
data-width="100%"
data-autoplay="false"
data-controls="true"
data-show-reactions="true">
<div class="video-placeholder">
<!-- SVG play icon + metadata -->
</div>
</div>
```
**Runtime Hydration:**
1. `LandingPage.tsx` mounts → Scans for `.video-block` elements
2. Reads `data-*` attributes
3. Creates React root for each block
4. Renders `AdvancedVideoPlayer` or `VideoPlayer` component
5. Replaces placeholder with live player
**Supported Attributes:**
- `data-video-id` (required) — Media library video ID
- `data-player-type` (`"standard"` or `"advanced"`, default: `"standard"`)
- `data-width` (CSS value, default: `"100%"`)
- `data-height` (CSS value, default: `"auto"`)
- `data-autoplay` (`"true"` or `"false"`, default: `"false"`)
- `data-controls` (`"true"` or `"false"`, default: `"true"`)
- `data-show-reactions` (`"true"` or `"false"`, default: `"true"`, advanced player only)
---
## Code Examples
### Creating a Page (TypeScript)
```typescript
import { api } from '@/lib/api';
async function createAboutPage() {
const { data } = await api.post('/pages', {
title: 'About Us',
description: 'Learn about our campaign',
editorMode: 'VISUAL',
});
console.log('Created page:', data.slug); // "about-us"
return data.id;
}
```
### Saving Editor State (GrapesJS)
```typescript
// In LandingPageEditor component
import { useRef } from 'react';
import GrapesJSEditor, { GrapesJSEditorHandle } from '@/components/GrapesJSEditor';
const editorRef = useRef<GrapesJSEditorHandle>(null);
const handleSave = () => {
editorRef.current?.triggerSave(); // Calls registered save command
};
const handleEditorSave = async (data: { projectData: any; html: string; css: string }) => {
await api.put(`/pages/${pageId}`, {
blocks: data.projectData,
htmlOutput: data.html,
cssOutput: data.css,
});
message.success('Page saved');
};
return (
<GrapesJSEditor
ref={editorRef}
initialData={page.blocks}
onSave={handleEditorSave}
/>
);
```
### Fetching Published Page (Public Route)
```typescript
import axios from 'axios';
async function loadLandingPage(slug: string) {
try {
const { data } = await axios.get(`/api/pages/${slug}/view`);
// Set SEO
document.title = data.seoTitle || data.title;
// Inject CSS
const style = document.createElement('style');
style.textContent = data.cssOutput || '';
document.head.appendChild(style);
// Render HTML
return data.htmlOutput;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
throw new Error('Page not found or unpublished');
}
throw error;
}
}
```
### MkDocs Export Logic (Backend)
```typescript
// From pages.service.ts
function wrapInMaterialOverride(html: string, css: string | null): string {
const styleBlock = css ? `<style>\n${css}\n</style>` : '';
return `{% extends "main.html" %}
{% block content %}
${styleBlock}
${html}
{% endblock %}
`;
}
async function exportToMkDocs(opts: ExportOptions): Promise<string> {
const { mkdocsPath, html, css, exportMode, title, seoTitle, seoDescription } = opts;
// Write override template
const filePath = path.join(MKDOCS_OVERRIDES, mkdocsPath);
const content = exportMode === 'STANDALONE'
? wrapInStandaloneDocument(html, css, seoTitle || title, seoDescription)
: wrapInMaterialOverride(html, css);
await fs.writeFile(filePath, content, 'utf-8');
// Write .md stub
const stubPath = mkdocsPath.replace(/\.html$/, '.md');
const stubContent = `---
template: ${mkdocsPath}
title: "${seoTitle || title}"
---
`;
await fs.writeFile(path.join(MKDOCS_DOCS_ROOT, stubPath), stubContent, 'utf-8');
return stubPath;
}
```
---
## Troubleshooting
### Problem: GrapesJS Editor Not Loading
**Symptoms:**
- Blank screen in editor
- Console error: `Cannot read property 'init' of undefined`
**Causes:**
- GrapesJS package not installed
- CSS import missing
- Plugin incompatibility
**Solutions:**
1. **Verify installation:**
```bash
cd admin && npm list grapesjs
# Should show: grapesjs@0.21.x
```
2. **Check CSS import:**
```typescript
// In GrapesJSEditor.tsx
import 'grapesjs/dist/css/grapes.min.css';
```
3. **Check browser console:**
- Look for `grapesjs` variable in global scope
- Verify all plugins loaded successfully
4. **Clear cache:**
```bash
# In browser DevTools
# Right-click Reload → Empty Cache and Hard Reload
```
---
### Problem: Published Page Not Rendering
**Symptoms:**
- 404 error at `/p/my-page`
- Page exists in database, `published=true`
**Causes:**
- React route not registered
- Slug mismatch
- Public route mounted incorrectly
**Solutions:**
1. **Verify route registration:**
```typescript
// In admin/src/App.tsx
<Route path="/p/:slug" element={<LandingPage />} />
```
2. **Check slug in URL:**
- Slug is case-sensitive: `/p/About-Us` ≠ `/p/about-us`
- Use lowercase, hyphenated: `/p/about-us`
3. **Test API directly:**
```bash
curl http://localhost:4000/api/pages/about-us/view
# Should return JSON, not 404
```
4. **Check published status:**
```sql
SELECT slug, published FROM landing_pages WHERE slug = 'about-us';
-- published should be true
```
---
### Problem: Mobile Warning Shows on Desktop
**Symptoms:**
- "Desktop Required" warning displays on 1920px screen
- Editor won't load
**Causes:**
- Browser window width < 768px
- Breakpoint detection failure
- DevTools docked (reduces viewport width)
**Solutions:**
1. **Check actual viewport width:**
```javascript
// In browser console
console.log(window.innerWidth);
// Should be > 768 for desktop
```
2. **Undock DevTools:**
- Press F12 → Click ⋮ (three dots) → Dock to right/bottom → Undock
- Increases available viewport width
3. **Verify breakpoint hook:**
```typescript
// In PageEditorPage.tsx
const screens = Grid.useBreakpoint();
const isMobile = !screens.md; // md = 768px
```
4. **Test responsive mode:**
- F12 → Toggle device toolbar (Ctrl+Shift+M)
- Select "Responsive" → Set width to 1024px
---
### Problem: MkDocs Export Not Found
**Symptoms:**
- MkDocs site shows 404 for `/pages/about-us/`
- Override file missing from `mkdocs/docs/overrides/`
**Causes:**
- Page not published
- `mkdocsSkipExport=true`
- Export path incorrect
- MkDocs not rebuilt
**Solutions:**
1. **Verify publish status:**
```sql
SELECT slug, published, mkdocs_skip_export FROM landing_pages WHERE slug = 'about-us';
-- Both should be true/false appropriately
```
2. **Check export path:**
```bash
ls -la mkdocs/docs/overrides/about.html
# Should exist if published and not skipped
```
3. **Validate exports:**
- Admin → Pages → "Validate Exports" button
- Check repair count
4. **Rebuild MkDocs:**
```bash
docker compose exec mkdocs mkdocs build
# Or in admin: Pages → "Build Site"
```
5. **Check template path in stub:**
```bash
cat mkdocs/docs/about.md
# Should show: template: about.html (NOT overrides/about.html)
```
---
### Problem: Slug Collision on Create
**Symptoms:**
- Create page with title "About Us" → slug becomes `about-us-2`
- Expected `about-us` but already taken
**Causes:**
- Existing page with same slug (possibly unpublished)
- Soft-deleted page (if soft delete implemented)
**Solutions:**
1. **Check existing pages:**
```sql
SELECT id, title, slug, published FROM landing_pages WHERE slug LIKE 'about-us%';
```
2. **Delete duplicate:**
- If old page is unwanted: Admin → Pages → Delete
- New page can reuse slug
3. **Use unique title:**
- Rename new page: "About Us 2026" → slug `about-us-2026`
4. **Manual slug override:**
- After create: Edit page → Settings → Override Path → `about-us-custom.html`
---
### Problem: Video Block Not Hydrating
**Symptoms:**
- Video placeholder shows on published page
- No player renders
- Console error: `Invalid video ID: PLACEHOLDER`
**Causes:**
- `data-video-id="PLACEHOLDER"` not replaced
- Video ID not numeric
- Hydration script not running
**Solutions:**
1. **Check video ID in editor:**
- Open GrapesJS editor → Select video block
- Properties panel → Video ID field should be numeric (e.g., `123`)
- Not `PLACEHOLDER`
2. **Verify HTML output:**
```html
<!-- Bad -->
<div class="video-block" data-video-id="PLACEHOLDER">...</div>
<!-- Good -->
<div class="video-block" data-video-id="42">...</div>
```
3. **Check hydration script:**
```typescript
// In LandingPage.tsx
useEffect(() => {
// Should scan for .video-block elements
const videoBlocks = contentRef.current?.querySelectorAll('.video-block');
console.log('Found video blocks:', videoBlocks?.length);
}, [page]);
```
4. **Test video ID validity:**
```bash
curl http://localhost:4100/api/media/videos/42
# Should return video metadata, not 404
```
---
## Performance Considerations
### Editor Initialization
**GrapesJS startup:** ~500ms on modern desktop
**Optimization strategies:**
- Lazy load GrapesJS: `const GrapesJS = lazy(() => import('./GrapesJSEditor'))`
- Show loading spinner during init
- Preload on hover over "Edit" button
### Large Pages
**Complexity threshold:** 100+ components
**Symptoms:**
- Laggy drag-and-drop
- Slow save operations
- Canvas rendering delay
**Mitigations:**
- Break into multiple pages (split hero + sections)
- Use CODE mode for complex layouts
- Minimize nested components
### htmlOutput Storage
**Database overhead:** `htmlOutput` can be 50KB+ for complex pages
**Considerations:**
- Indexed by `published` for public queries (fast)
- Not indexed by content (no full-text search on HTML)
- Consider external storage for very large pages (future enhancement)
### Public Page Rendering
**React hydration:** Video blocks hydrate after initial render (~100ms delay)
**Performance tips:**
- Use `dangerouslySetInnerHTML` for immediate HTML paint
- Defer video hydration to `setTimeout(..., 100)`
- Preload video metadata for above-fold players
---
## Security Considerations
### Admin-Authored HTML
**Risk:** XSS via malicious HTML in editor
**Mitigation:**
- **Accepted risk:** Only admins can create/edit pages (trusted users)
- **No user-supplied content:** Public users cannot edit landing pages
- **Authentication required:** All write endpoints require admin role
**Comment in code:**
```typescript
// HTML/CSS is admin-authored via GrapesJS editor (not user-submitted content).
// Only authenticated admins can create/edit pages, so XSS risk is accepted.
return <div dangerouslySetInnerHTML={{ __html: page.htmlOutput }} />;
```
### Slug Validation
**Attack vector:** Path traversal via slug injection
**Protection:**
```typescript
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // Alphanumeric + hyphens only
.replace(/^-+|-+$/g, '') // Trim leading/trailing hyphens
.slice(0, 80); // Max 80 chars
}
```
**Safe slugs:** `about-us`, `campaign-2026`, `contact`
**Rejected:** `../etc/passwd`, `<script>alert(1)</script>`, `../../admin`
### MkDocs Path Validation
**Attack vector:** Write arbitrary files via path traversal in `mkdocsPath`
**Protection:**
```typescript
function validateMkdocsPath(mkdocsPath: string): void {
if (mkdocsPath.includes('\0')) throw new Error('Null byte detected');
const normalized = path.normalize(mkdocsPath);
if (normalized.includes('..') || path.isAbsolute(normalized)) {
throw new Error('Path traversal not allowed');
}
if (mkdocsPath.includes('%2e') || mkdocsPath.includes('%2E')) {
throw new Error('Encoded path traversal not allowed');
}
if (!mkdocsPath.endsWith('.html')) {
throw new Error('Path must end with .html');
}
}
```
**Safe paths:** `about.html`, `pages/contact.html`
**Rejected:** `../../../etc/passwd.html`, `/etc/shadow.html`, `%2e%2e/admin.html`
### Published Flag Enforcement
**Attack vector:** Access draft pages via public route
**Protection:**
```typescript
// In pagesService.findBySlugPublic()
if (!page || !page.published) {
throw new AppError(404, 'Page not found', 'PAGE_NOT_FOUND');
}
```
**Behavior:**
- Unpublished pages return 404 on public route
- Admin routes bypass check (can view drafts)
---
## Related Documentation
### Frontend Components
- **[LandingPageEditor](/v2/frontend/pages/LandingPageEditor)** — Full-screen editor wrapper
- **[LandingPagesPage](/v2/frontend/pages/LandingPagesPage)** — Table view + CRUD
- **[GrapesJSEditor](/v2/frontend/components/GrapesJSEditor)** — GrapesJS wrapper with forwardRef
- **[PublicLandingPage](/v2/frontend/pages/public/LandingPage)** — Public page renderer
### Backend Modules
- **[pages-admin.routes](/v2/backend/modules/pages/pages-admin.routes)** — Admin CRUD endpoints
- **[pages-public.routes](/v2/backend/modules/pages/pages-public.routes)** — Public view endpoint
- **[pages.service](/v2/backend/modules/pages/pages.service)** — Business logic + MkDocs export
- **[pages.schemas](/v2/backend/modules/pages/pages.schemas)** — Zod validation schemas
### Database
- **[LandingPage Model](/v2/database/models/pages)** — Schema + relationships
- **[PageBlock Model](/v2/database/models/pages)** — Block library schema
### Feature Documentation
- **[GrapesJS Editor Integration](grapes-editor.md)** — forwardRef pattern + custom blocks
- **[Block Library](block-library.md)** — Reusable components system
- **[MkDocs Export](mkdocs-export.md)** — Material theme integration
### External Resources
- [GrapesJS Documentation](https://grapesjs.com/docs/) — Official editor docs
- [GrapesJS Plugins](https://grapesjs.com/docs/plugins/) — Available plugins
- [MkDocs Material](https://squidfunk.github.io/mkdocs-material/) — Theme docs
- [Jinja2 Templates](https://jinja.palletsprojects.com/) — Template syntax