1164 lines
29 KiB
Markdown
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
|