27 KiB

PageEditorPage

Overview

File: admin/src/pages/PageEditorPage.tsx

Route: /app/pages/:id/edit

Role Requirements: Any authenticated user (uses authenticate middleware)

Purpose: Provides a full-screen dual-mode editor for landing pages with visual WYSIWYG editing (GrapesJS) and raw HTML code editing (Monaco Editor). This page is the primary interface for creating and editing landing pages, supporting both drag-and-drop visual design for non-technical users and direct HTML/CSS editing for developers. The editor operates in full-screen mode without the AppLayout wrapper to maximize editing space.

Key Features:

  • Dual editor modes (Visual/Code) toggled per page
  • Full-screen editing interface (no AppLayout)
  • GrapesJS visual editor with custom blocks
  • Monaco Editor for raw HTML editing
  • Real-time save with Ctrl+S keyboard shortcut
  • Live preview for published pages
  • Publish/unpublish toggle
  • Mobile device detection with warning screen
  • Auto-save on Ctrl+S in code mode
  • Editor state managed via useRef for performance

Layout: Full-screen (no AppLayout wrapper)

Dependencies:

  • Ant Design v5 (Button, Switch, Space, Typography, Tag, Spin, Grid, Result)
  • Monaco Editor (@monaco-editor/react)
  • GrapesJS (via GrapesJSEditor component wrapper)
  • react-router-dom (useParams, useNavigate)

Features

1. Dual Editor Modes

Visual Mode (GrapesJS):

  • Drag-and-drop interface
  • Custom block library (loaded from API)
  • Component tree navigation
  • Style manager (CSS properties)
  • Trait manager (component attributes)
  • Asset manager (images, files)
  • Canvas preview (desktop/tablet/mobile)
  • Undo/redo
  • Full-screen toggle
  • Export HTML/CSS

Code Mode (Monaco Editor):

  • Syntax highlighting for HTML
  • Line numbers
  • Word wrap enabled
  • Auto-formatting
  • Dark theme
  • Minimap disabled for cleaner view
  • Automatic layout adjustment
  • Ctrl+S keyboard shortcut for save
  • Direct HTML editing (no CSS/JS extraction)

Mode Selection:

  • Set when creating page in LandingPagesPage
  • editorMode field: "VISUAL" or "CODE"
  • Cannot switch modes within editor (navigate back to pages list to change)
  • Mode displayed as colored tag in toolbar

2. Toolbar Controls

Left Section:

  • Back Button - Navigate to pages list
  • Page Title - Current page name
  • Slug Display - Public URL preview (/p/:slug)
  • Mode Tag - Visual (green) or Code (blue)

Right Section:

  • Published Toggle - Switch to enable/disable public access
  • Live Tag - Visible when published
  • Preview Button - Opens public page in new tab (only when published)
  • Save Button - Manual save trigger (primary action)

3. Auto-Save & Keyboard Shortcuts

Code Mode Shortcuts:

  • Ctrl+S / Cmd+S - Save page (prevents browser default)
  • Keyboard event handler registered on mount
  • Handler cleaned up on unmount

Visual Mode Save:

  • Save button triggers editorRef.current?.triggerSave()
  • GrapesJS editor handles internal save via forwardRef

4. Mobile Device Detection

Mobile Warning:

  • Detects screen width < 768px (md breakpoint)
  • Shows Result component with "Desktop Required" message
  • "Back to Pages" button for navigation
  • Prevents editor loading on mobile devices
  • Different message for visual vs code mode

5. Loading & Error States

Loading State:

  • Full-screen centered spinner
  • Displayed while fetching page data + blocks
  • Minimum height: 100vh

Error Handling:

  • Failed fetch shows error message
  • Auto-navigates back to pages list
  • Prevents editor render on missing page

User Workflow

Opening a Page for Editing

  1. Navigate to Pages List:

    • Go to /app/pages (LandingPagesPage)
    • View table of all landing pages
  2. Select Page to Edit:

    • Click "Edit" button in page row
    • Opens editor in full-screen mode
    • URL changes to /app/pages/:id/edit
  3. Wait for Editor Load:

    • Loading spinner appears
    • Page data fetched from API
    • Visual mode: block library also loaded
    • Editor renders based on mode

Editing in Visual Mode

  1. Use GrapesJS Interface:

    • Add Components: Drag blocks from left sidebar onto canvas
    • Move Components: Click and drag to reposition
    • Edit Text: Double-click text to edit inline
    • Style Components: Select component, use Style Manager in right panel
    • Change Attributes: Use Trait Manager for component properties
    • Upload Images: Use Asset Manager to add media
  2. Canvas Controls:

    • Toggle device preview (desktop/tablet/mobile)
    • Toggle fullscreen mode
    • Toggle borders/padding visualization
  3. Save Changes:

    • Click "Save" button in toolbar (or Ctrl+S)
    • Editor extracts:
      • Project data (component tree JSON)
      • Rendered HTML output
      • Compiled CSS styles
    • All three sent to API via PUT request
    • Success message: "Page saved"

Editing in Code Mode

  1. Edit HTML Directly:

    • Monaco editor displays current HTML output
    • Edit HTML structure, inline styles, content
    • Syntax highlighting for HTML tags
  2. Save Changes:

    • Press Ctrl+S (or Cmd+S on Mac)
    • Or click "Save" button in toolbar
    • Raw HTML content sent to API
    • Success message: "Page saved"
  3. Limitations:

    • No visual preview within editor
    • Must publish and use Preview button to see changes
    • Changes don't update GrapesJS project data (one-way sync)

Publishing a Page

  1. Toggle Published Switch:

    • Switch in top-right toolbar
    • Green = published, Gray = unpublished
    • API updates published field immediately
  2. When Published:

    • "Live" tag appears next to switch
    • "Preview" button becomes visible
    • Page accessible at /p/:slug URL
  3. Preview Published Page:

    • Click "Preview" button (eye icon)
    • Opens new browser tab to /p/:slug
    • Shows rendered page as public users see it

Component Breakdown

Main Component Structure

export default function PageEditorPage() {
  const { id } = useParams<{ id: string }>();
  const navigate = useNavigate();
  const screens = Grid.useBreakpoint();
  const isMobile = !screens.md;
  const { token } = theme.useToken();

  // State
  const [page, setPage] = useState<LandingPage | null>(null);
  const [blocks, setBlocks] = useState<PageBlock[]>([]);
  const [loading, setLoading] = useState(true);
  const [saving, setSaving] = useState(false);
  const [codeContent, setCodeContent] = useState('');
  const editorRef = useRef<GrapesJSEditorHandle>(null);

  // Derived state
  const isCodeMode = page?.editorMode === 'CODE';

  // Fetch page + blocks (Visual mode only)
  useEffect(() => {
    const fetchData = async () => {
      try {
        if (isCodeMode) {
          const pageRes = await api.get<LandingPage>(`/pages/${id}`);
          setPage(pageRes.data);
          setCodeContent(pageRes.data.htmlOutput || '');
        } else {
          const [pageRes, blocksRes] = await Promise.all([
            api.get<LandingPage>(`/pages/${id}`),
            api.get<PageBlock[]>('/page-blocks'),
          ]);
          setPage(pageRes.data);
          setBlocks(blocksRes.data);
          setCodeContent(pageRes.data.htmlOutput || '');
        }
      } catch {
        message.error('Failed to load page');
        navigate('/app/pages');
      } finally {
        setLoading(false);
      }
    };
    fetchData();
  }, [id]);

  // Ctrl+S keyboard shortcut (code mode only)
  useEffect(() => {
    if (!isCodeMode) return;
    const handler = (e: KeyboardEvent) => {
      if ((e.ctrlKey || e.metaKey) && e.key === 's') {
        e.preventDefault();
        handleSaveCode();
      }
    };
    window.addEventListener('keydown', handler);
    return () => window.removeEventListener('keydown', handler);
  }, [isCodeMode, handleSaveCode]);

  // Conditional render based on state
  if (loading) return <Spin />;
  if (!page) return null;
  if (isMobile) return <MobileWarning />;

  return (
    <div style={{ height: '100vh' }}>
      <Toolbar />
      {isCodeMode ? <MonacoEditor /> : <GrapesJSEditor />}
    </div>
  );
}

Toolbar Component

Structure:

  • Full-width sticky header
  • Dark background (colorBgBase)
  • Border bottom separator
  • Two-column layout (Space components)

Left Section:

<Space>
  <Button type="text" icon={<ArrowLeftOutlined />} onClick={goBack} />
  <Text strong>{page.title}</Text>
  <Text>/p/{page.slug}</Text>
  <Tag color={isCodeMode ? 'blue' : 'green'}>
    {isCodeMode ? 'Code' : 'Visual'}
  </Tag>
</Space>

Right Section:

<Space>
  <Space size={4}>
    <Text>Published</Text>
    <Switch checked={page.published} onChange={handleTogglePublished} />
  </Space>
  {page.published && <Tag color="green">Live</Tag>}
  {page.published && (
    <Button icon={<EyeOutlined />} onClick={() => window.open(`/p/${page.slug}`)}>
      Preview
    </Button>
  )}
  <Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
    Save
  </Button>
</Space>

GrapesJS Editor Integration

Component:

<GrapesJSEditor
  ref={editorRef}
  initialData={page.blocks as Record<string, unknown>}
  onSave={handleSaveVisual}
  customBlocks={blocks}
/>

Save Callback:

const handleSaveVisual = useCallback(async (data: {
  projectData: Record<string, unknown>;
  html: string;
  css: string;
}) => {
  setSaving(true);
  try {
    const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {
      blocks: data.projectData,
      htmlOutput: data.html,
      cssOutput: data.css,
    });
    setPage(updated);
    message.success('Page saved');
  } catch {
    message.error('Failed to save page');
  } finally {
    setSaving(false);
  }
}, [page]);

Trigger Save (from parent):

editorRef.current?.triggerSave();

Monaco Editor Integration

Component:

<Editor
  height="100%"
  defaultLanguage="html"
  theme="vs-dark"
  value={codeContent}
  onChange={(value) => setCodeContent(value ?? '')}
  options={{
    wordWrap: 'on',
    minimap: { enabled: false },
    fontSize: 14,
    scrollBeyondLastLine: false,
    automaticLayout: true,
  }}
/>

Save Handler:

const handleSaveCode = useCallback(async () => {
  setSaving(true);
  try {
    const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {
      htmlOutput: codeContent,
    });
    setPage(updated);
    message.success('Page saved');
  } catch {
    message.error('Failed to save page');
  } finally {
    setSaving(false);
  }
}, [page, codeContent]);

Mobile Warning Component

Conditional Render:

if (isMobile) {
  return (
    <div style={{
      display: 'flex',
      flexDirection: 'column',
      alignItems: 'center',
      justifyContent: 'center',
      height: '100vh',
      padding: 24,
      background: token.colorBgBase,
    }}>
      <Result
        status="info"
        title="Desktop Required"
        subTitle={`The ${isCodeMode ? 'code' : 'page'} editor requires a desktop browser. Please switch to a larger screen to edit this page.`}
        extra={
          <Button type="primary" onClick={() => navigate('/app/pages')}>
            Back to Pages
          </Button>
        }
      />
    </div>
  );
}

Breakpoint Detection:

  • Uses Grid.useBreakpoint() hook
  • isMobile = !screens.md (screen width < 768px)
  • Early return prevents editor initialization

State Management

Local Component State (useState)

// Page data
const [page, setPage] = useState<LandingPage | null>(null);

// Block library (Visual mode only)
const [blocks, setBlocks] = useState<PageBlock[]>([]);

// Loading state
const [loading, setLoading] = useState(true);

// Save in progress state
const [saving, setSaving] = useState(false);

// Monaco editor content (Code mode)
const [codeContent, setCodeContent] = useState('');

Refs (useRef)

// GrapesJS editor handle
const editorRef = useRef<GrapesJSEditorHandle>(null);

Why useRef?

  • GrapesJS editor controlled externally
  • Parent triggers save via editorRef.current?.triggerSave()
  • No re-renders when editor state changes
  • Performance optimization for large canvas

Derived State

// Computed from page data
const isCodeMode = page?.editorMode === 'CODE';

// Responsive breakpoint
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;

// Theme tokens
const { token } = theme.useToken();

State Flow

  1. Component Mounts:

    • loading set to true
    • useEffect triggers fetch based on mode
    • Visual mode: parallel fetch page + blocks
    • Code mode: fetch page only
    • Sets page, blocks, codeContent
    • Sets loading to false
  2. User Edits Content:

    • Visual mode: GrapesJS manages internal state
    • Code mode: Monaco onChange updates codeContent
  3. User Saves:

    • Visual mode: editorRef.current?.triggerSave()handleSaveVisual callback
    • Code mode: handleSaveCode directly
    • Sets saving to true
    • API PUT request
    • Updates page with response
    • Sets saving to false
  4. User Toggles Published:

    • API PUT request with published field
    • Updates page with response
    • UI updates (Live tag, Preview button)

API Integration

Endpoints Used

  1. GET /api/pages/:id - Fetch page data
  2. GET /api/page-blocks - Fetch custom block library (Visual mode only)
  3. PUT /api/pages/:id - Update page (save, publish)

API Calls

1. Fetch Page + Blocks (Visual Mode)

const [pageRes, blocksRes] = await Promise.all([
  api.get<LandingPage>(`/pages/${id}`),
  api.get<PageBlock[]>('/page-blocks'),
]);
setPage(pageRes.data);
setBlocks(blocksRes.data);
setCodeContent(pageRes.data.htmlOutput || '');

LandingPage Response:

{
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "title": "Campaign Launch",
  "slug": "campaign-launch",
  "editorMode": "VISUAL",
  "blocks": {
    "pages": [...],
    "styles": [...],
    "components": [...]
  },
  "htmlOutput": "<html>...</html>",
  "cssOutput": ".container { ... }",
  "published": false,
  "createdAt": "2025-02-10T12:00:00Z",
  "updatedAt": "2025-02-10T14:30:00Z"
}

PageBlock Response:

[
  {
    "id": "block-hero",
    "label": "Hero Section",
    "category": "sections",
    "content": "<div class='hero'>...</div>",
    "media": "<svg>...</svg>",
    "attributes": { "class": "gjs-block" }
  },
  ...
]

2. Fetch Page Only (Code Mode)

const pageRes = await api.get<LandingPage>(`/pages/${id}`);
setPage(pageRes.data);
setCodeContent(pageRes.data.htmlOutput || '');

3. Save Visual Mode Changes

const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {
  blocks: data.projectData,
  htmlOutput: data.html,
  cssOutput: data.css,
});
setPage(updated);

Request Body:

{
  "blocks": {
    "pages": [...],
    "styles": [...],
    "components": [...]
  },
  "htmlOutput": "<html>...</html>",
  "cssOutput": ".container { ... }"
}

4. Save Code Mode Changes

const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {
  htmlOutput: codeContent,
});
setPage(updated);

Request Body:

{
  "htmlOutput": "<!DOCTYPE html>\n<html>...</html>"
}

5. Toggle Published

const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {
  published: !page.published,
});
setPage(updated);
message.success(updated.published ? 'Page published' : 'Page unpublished');

Request Body:

{
  "published": true
}

Code Examples

Complete Save Visual Mode Flow

const handleSaveVisual = useCallback(async (data: {
  projectData: Record<string, unknown>;
  html: string;
  css: string;
}) => {
  if (!page) return;
  setSaving(true);
  try {
    const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {
      blocks: data.projectData,
      htmlOutput: data.html,
      cssOutput: data.css,
    });
    setPage(updated);
    message.success('Page saved');
  } catch {
    message.error('Failed to save page');
  } finally {
    setSaving(false);
  }
}, [page]);

Keyboard Shortcut Handler

useEffect(() => {
  if (!isCodeMode) return;  // Only in code mode

  const handler = (e: KeyboardEvent) => {
    if ((e.ctrlKey || e.metaKey) && e.key === 's') {
      e.preventDefault();  // Prevent browser save dialog
      handleSaveCode();
    }
  };

  window.addEventListener('keydown', handler);
  return () => window.removeEventListener('keydown', handler);  // Cleanup
}, [isCodeMode, handleSaveCode]);

Conditional API Fetch Pattern

useEffect(() => {
  const fetchData = async () => {
    try {
      if (isCodeMode) {
        // Code mode: page only
        const pageRes = await api.get<LandingPage>(`/pages/${id}`);
        setPage(pageRes.data);
        setCodeContent(pageRes.data.htmlOutput || '');
      } else {
        // Visual mode: page + blocks in parallel
        const [pageRes, blocksRes] = await Promise.all([
          api.get<LandingPage>(`/pages/${id}`),
          api.get<PageBlock[]>('/page-blocks'),
        ]);
        setPage(pageRes.data);
        setBlocks(blocksRes.data);
        setCodeContent(pageRes.data.htmlOutput || '');
      }
    } catch {
      message.error('Failed to load page');
      navigate('/app/pages');
    } finally {
      setLoading(false);
    }
  };
  fetchData();
}, [id]); // Only re-fetch if page ID changes

Editor Ref Save Trigger

// Parent component
<Button
  type="primary"
  icon={<SaveOutlined />}
  loading={saving}
  onClick={() => {
    if (isCodeMode) {
      handleSaveCode();
    } else {
      editorRef.current?.triggerSave();  // Trigger GrapesJS save
    }
  }}
>
  Save
</Button>

// GrapesJSEditor component
<GrapesJSEditor
  ref={editorRef}
  initialData={page.blocks}
  onSave={handleSaveVisual}  // Callback receives extracted data
  customBlocks={blocks}
/>

Performance Considerations

1. Parallel API Requests (Visual Mode)

const [pageRes, blocksRes] = await Promise.all([
  api.get<LandingPage>(`/pages/${id}`),
  api.get<PageBlock[]>('/page-blocks'),
]);

Benefit: Reduces loading time by ~50% (2 sequential requests → 1 parallel batch).

2. Conditional Block Loading

if (isCodeMode) {
  // Skip blocks fetch in code mode
  const pageRes = await api.get<LandingPage>(`/pages/${id}`);
} else {
  // Load blocks only for visual mode
  const [pageRes, blocksRes] = await Promise.all([...]);
}

Benefit: Saves unnecessary API call in code mode (blocks not used).

3. useRef for Editor Handle

const editorRef = useRef<GrapesJSEditorHandle>(null);

Why useRef?

  • GrapesJS editor has large internal state (component tree, styles, assets)
  • useRef prevents re-renders when editor state changes
  • Parent only needs to trigger save, not track editor state
  • Performance critical for large page designs

4. useCallback for Save Handlers

const handleSaveVisual = useCallback(async (data) => {
  // ...
}, [page]);  // Only recreate when page changes

const handleSaveCode = useCallback(async () => {
  // ...
}, [page, codeContent]);  // Only recreate when deps change

Benefit: Prevents unnecessary function recreation on every render.

5. Early Mobile Detection

if (isMobile) {
  return <MobileWarning />;  // No editor initialization
}

Benefit: Skips heavy editor initialization on mobile devices (saves memory + CPU).

6. Automatic Monaco Layout

<Editor
  options={{
    automaticLayout: true,  // Auto-adjust on window resize
    minimap: { enabled: false },  // Disable minimap to save CPU
    scrollBeyondLastLine: false,  // Reduce DOM size
  }}
/>

Benefit: Reduces Monaco memory footprint by disabling minimap (can use 100MB+ on large files).


Responsive Design

Mobile Detection

Breakpoint:

  • Uses Grid.useBreakpoint() hook
  • Mobile if !screens.md (screen width < 768px)

Mobile Warning Screen:

if (isMobile) {
  return (
    <div style={{
      display: 'flex',
      flexDirection: 'column',
      alignItems: 'center',
      justifyContent: 'center',
      height: '100vh',
      padding: 24,
      background: token.colorBgBase,
    }}>
      <Result
        status="info"
        title="Desktop Required"
        subTitle={`The ${isCodeMode ? 'code' : 'page'} editor requires a desktop browser...`}
        extra={
          <Button type="primary" onClick={() => navigate('/app/pages')}>
            Back to Pages
          </Button>
        }
      />
    </div>
  );
}

Why Mobile Warning?

  • GrapesJS requires large screen for drag-and-drop UI (canvas + panels + toolbar)
  • Monaco editor impractical on mobile keyboards
  • Touch gestures conflict with editor interactions
  • Better UX to redirect users to desktop device

Full-Screen Layout

No AppLayout Wrapper:

  • Page routed outside AppLayout component
  • Uses full viewport height (100vh)
  • No sidebar navigation
  • Maximizes editing canvas space

Layout Structure:

<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
  <Toolbar />  {/* Fixed header */}
  <Editor />   {/* Flex-grow to fill remaining space */}
</div>

Accessibility

Keyboard Navigation

  1. Tab Key:

    • Cycles through toolbar buttons (Back, Save, Preview, Toggle)
    • Enters editor focus (Monaco or GrapesJS canvas)
  2. Ctrl+S / Cmd+S:

    • Save shortcut (code mode only)
    • Prevents browser default "Save Page As" dialog
  3. GrapesJS Keyboard Shortcuts:

    • Ctrl+Z: Undo
    • Ctrl+Shift+Z: Redo
    • Delete: Remove selected component
    • Ctrl+C/V: Copy/paste components
  4. Monaco Editor Shortcuts:

    • Ctrl+S: Save (custom handler)
    • Ctrl+F: Find
    • Ctrl+H: Find and replace
    • Ctrl+/: Toggle comment
    • Alt+Up/Down: Move line up/down

ARIA Labels

<Button
  type="text"
  icon={<ArrowLeftOutlined />}
  onClick={() => navigate('/app/pages')}
  aria-label="Back to pages list"
/>

Screen Reader Announcements:

  • Button labels announced via aria-label
  • Switch state announced ("Published" / "Unpublished")
  • Tag colors announced by screen readers

Focus Management

Toolbar Focus Order:

  1. Back button
  2. Published switch
  3. Preview button (if visible)
  4. Save button

Editor Focus:

  • Monaco: automatic focus management via Monaco API
  • GrapesJS: focus enters canvas on click

Troubleshooting

Problem: Editor Not Loading

Symptoms:

  • Blank screen after loading spinner disappears
  • Console errors related to GrapesJS or Monaco

Solutions:

  1. Check page data in API response:

    curl -H "Authorization: Bearer <token>" \
      http://localhost:4000/api/pages/<page-id>
    
    • Verify blocks, htmlOutput, editorMode fields exist
  2. Check browser console:

    • Open DevTools Console (F12)
    • Look for JavaScript errors
    • Common errors:
      • "Cannot read property 'pages' of undefined" → blocks field missing/corrupt
      • "Monaco Editor failed to load" → CDN blocked or slow network
  3. Clear browser cache:

    • Monaco and GrapesJS cache resources
    • Ctrl+Shift+R (hard refresh)
  4. Check network tab:

    • Verify API requests complete successfully
    • Verify block library loads (Visual mode)

Problem: Save Button Not Working

Symptoms:

  • Click Save button, no success message
  • Loading spinner appears but never completes
  • Console shows 400/500 errors

Solutions:

  1. Check API request in Network tab:

    • Look for PUT /api/pages/:id request
    • Check request payload (should have htmlOutput, blocks, cssOutput)
    • Check response status code
  2. Visual Mode - Invalid blocks data:

    • GrapesJS may generate invalid JSON
    • Check console for serialization errors
    • Try creating new page instead of editing corrupt one
  3. Code Mode - Invalid HTML:

    • API may validate HTML structure
    • Check for missing closing tags
    • Check for script injection attempts (blocked by CSP)
  4. Network timeout:

    • Large pages (>1MB HTML) may timeout
    • Increase Axios timeout in admin/src/lib/api.ts
    • Optimize HTML output (minify, remove unused CSS)

Problem: Ctrl+S Not Saving (Code Mode)

Symptoms:

  • Press Ctrl+S, nothing happens
  • Browser "Save Page As" dialog appears instead

Solutions:

  1. Check browser focus:

    • Ensure Monaco editor is focused (click inside editor)
    • Keyboard handler requires window focus
  2. Check browser extensions:

    • Extensions may intercept Ctrl+S
    • Test in incognito mode
    • Disable extensions one by one
  3. Mac users: Use Cmd+S instead of Ctrl+S

    • Handler supports both e.ctrlKey and e.metaKey
  4. Manual save as fallback:

    • Click "Save" button in toolbar
    • Same effect as Ctrl+S

Problem: Published Page Not Accessible

Symptoms:

  • Toggle "Published" switch to ON
  • Navigate to /p/:slug, get 404 error

Solutions:

  1. Check slug uniqueness:

    • Slug must be unique across all pages
    • Check for URL conflicts with existing routes
  2. Check page published status:

    curl -H "Authorization: Bearer <token>" \
      http://localhost:4000/api/pages/<page-id>
    
    • Verify "published": true in response
  3. Check public route registration:

    • Open admin/src/App.tsx
    • Verify public route exists:
      <Route path="/p/:slug" element={<LandingPage />} />
      
  4. Check nginx routing:

    • Public pages served through nginx
    • Verify nginx reverse proxy configuration
  5. Hard refresh public page:

    • Ctrl+Shift+R to bypass cache
    • Browser may cache 404 response

Problem: GrapesJS Not Loading Custom Blocks

Symptoms:

  • Visual editor loads but block panel is empty
  • Only default blocks visible (Text, Image, etc.)

Solutions:

  1. Check blocks API response:

    curl -H "Authorization: Bearer <token>" \
      http://localhost:4000/api/page-blocks
    
    • Should return array of blocks with label, content, media
  2. Check blocks passed to GrapesJS:

    • Add console.log in PageEditorPage:
      console.log('Custom blocks:', blocks);
      
    • Verify array not empty
  3. Check GrapesJS block registration:

    • Open admin/src/components/GrapesJSEditor.tsx
    • Verify blocks registered in editor.BlockManager.add()
  4. Clear GrapesJS localStorage:

    • GrapesJS caches project data
    • Open DevTools → Application → Local Storage
    • Delete keys starting with gjsProject-