feat: Page Editor live preview
- Add POST /preview/page/{slug} and /preview/template/{cpt} endpoints
- Render full HTML using PageSSR for iframe preview
- Templates use sample post for dynamic placeholder resolution
- PageSettings iframe with debounced section updates (500ms)
- Desktop/Mobile toggle with scaled iframe view
- Show/Hide preview toggle button
- Refresh button for manual preview reload
- Preview indicator banner in iframe
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Settings, Eye, Smartphone, Monitor, ExternalLink } from 'lucide-react';
|
||||
import { Settings, Eye, Smartphone, Monitor, ExternalLink, RefreshCw, Loader2 } from 'lucide-react';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
@@ -40,6 +41,7 @@ interface AvailableSource {
|
||||
interface PageSettingsProps {
|
||||
page: PageItem | null;
|
||||
section: Section | null;
|
||||
sections: Section[]; // All sections for preview
|
||||
onSectionUpdate: (section: Section) => void;
|
||||
isTemplate?: boolean;
|
||||
availableSources?: AvailableSource[];
|
||||
@@ -111,11 +113,84 @@ const COLOR_SCHEMES = [
|
||||
export function PageSettings({
|
||||
page,
|
||||
section,
|
||||
sections,
|
||||
onSectionUpdate,
|
||||
isTemplate = false,
|
||||
availableSources = [],
|
||||
}: PageSettingsProps) {
|
||||
const [previewMode, setPreviewMode] = React.useState<'desktop' | 'mobile'>('desktop');
|
||||
const [previewMode, setPreviewMode] = useState<'desktop' | 'mobile'>('desktop');
|
||||
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const previewTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Debounced preview fetch
|
||||
useEffect(() => {
|
||||
if (!page || !showPreview) return;
|
||||
|
||||
// Clear existing timeout
|
||||
if (previewTimeoutRef.current) {
|
||||
clearTimeout(previewTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce preview updates
|
||||
previewTimeoutRef.current = setTimeout(async () => {
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const endpoint = page.type === 'page'
|
||||
? `/preview/page/${page.slug}`
|
||||
: `/preview/template/${page.cpt}`;
|
||||
|
||||
const response = await api.post(endpoint, { sections });
|
||||
if (response?.html) {
|
||||
setPreviewHtml(response.html);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Preview error:', error);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
if (previewTimeoutRef.current) {
|
||||
clearTimeout(previewTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [page, sections, showPreview]);
|
||||
|
||||
// Update iframe when HTML changes
|
||||
useEffect(() => {
|
||||
if (iframeRef.current && previewHtml) {
|
||||
const doc = iframeRef.current.contentDocument;
|
||||
if (doc) {
|
||||
doc.open();
|
||||
doc.write(previewHtml);
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
}, [previewHtml]);
|
||||
|
||||
// Manual refresh
|
||||
const handleRefreshPreview = async () => {
|
||||
if (!page) return;
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const endpoint = page.type === 'page'
|
||||
? `/preview/page/${page.slug}`
|
||||
: `/preview/template/${page.cpt}`;
|
||||
|
||||
const response = await api.post(endpoint, { sections });
|
||||
if (response?.html) {
|
||||
setPreviewHtml(response.html);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Preview error:', error);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update section prop
|
||||
const updateProp = (name: string, value: any, isDynamic?: boolean) => {
|
||||
@@ -310,16 +385,30 @@ export function PageSettings({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Preview Toggle */}
|
||||
{/* Preview Panel */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Eye className="w-4 h-4" />
|
||||
{__('Preview')}
|
||||
<CardTitle className="text-sm flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Eye className="w-4 h-4" />
|
||||
{__('Preview')}
|
||||
</span>
|
||||
{showPreview && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={handleRefreshPreview}
|
||||
disabled={previewLoading}
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${previewLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
{/* Preview Mode Toggle */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<Button
|
||||
variant={previewMode === 'desktop' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
@@ -337,9 +426,57 @@ export function PageSettings({
|
||||
{__('Mobile')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
{__('Live preview will be available after saving.')}
|
||||
</p>
|
||||
|
||||
{/* Preview Toggle */}
|
||||
{!showPreview ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowPreview(true)}
|
||||
disabled={!page}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
{__('Show Preview')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* Preview Iframe Container */}
|
||||
<div
|
||||
className="relative bg-gray-100 rounded-lg overflow-hidden border"
|
||||
style={{
|
||||
height: '300px',
|
||||
width: previewMode === 'mobile' ? '200px' : '100%',
|
||||
margin: previewMode === 'mobile' ? '0 auto' : undefined,
|
||||
}}
|
||||
>
|
||||
{previewLoading && (
|
||||
<div className="absolute inset-0 bg-white/80 flex items-center justify-center z-10">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="w-full h-full border-0"
|
||||
title="Page Preview"
|
||||
sandbox="allow-same-origin"
|
||||
style={{
|
||||
transform: previewMode === 'mobile' ? 'scale(0.5)' : 'scale(0.4)',
|
||||
transformOrigin: 'top left',
|
||||
width: previewMode === 'mobile' ? '400px' : '250%',
|
||||
height: previewMode === 'mobile' ? '600px' : '750px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={() => setShowPreview(false)}
|
||||
>
|
||||
{__('Hide Preview')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -235,6 +235,7 @@ export default function AppearancePages() {
|
||||
<PageSettings
|
||||
page={selectedPage}
|
||||
section={selectedSection}
|
||||
sections={structure?.sections || []}
|
||||
onSectionUpdate={handleSectionUpdate}
|
||||
isTemplate={selectedPage?.type === 'template'}
|
||||
availableSources={pageData?.available_sources || []}
|
||||
|
||||
@@ -68,6 +68,20 @@ class PagesController
|
||||
'callback' => [__CLASS__, 'create_page'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Preview page (render HTML for iframe)
|
||||
register_rest_route($namespace, '/preview/page/(?P<slug>[a-zA-Z0-9_-]+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'render_page_preview'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Preview template (render HTML for iframe)
|
||||
register_rest_route($namespace, '/preview/template/(?P<cpt>[a-zA-Z0-9_-]+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'render_template_preview'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,4 +484,204 @@ class PagesController
|
||||
|
||||
return $seo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render page preview HTML (for editor iframe)
|
||||
*/
|
||||
public static function render_page_preview(WP_REST_Request $request)
|
||||
{
|
||||
$slug = $request->get_param('slug');
|
||||
$body = $request->get_json_params();
|
||||
|
||||
// Get sections from POST body (unsaved changes)
|
||||
$sections = $body['sections'] ?? [];
|
||||
|
||||
// Find page for title
|
||||
$page = get_page_by_path($slug);
|
||||
$title = $page ? $page->post_title : 'Preview';
|
||||
|
||||
// Render HTML
|
||||
$html = self::render_preview_html($title, $sections, 'page');
|
||||
|
||||
// Return as HTML response
|
||||
return new WP_REST_Response([
|
||||
'html' => $html,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render template preview HTML (for editor iframe)
|
||||
*/
|
||||
public static function render_template_preview(WP_REST_Request $request)
|
||||
{
|
||||
$cpt = $request->get_param('cpt');
|
||||
$body = $request->get_json_params();
|
||||
|
||||
// Get sections from POST body
|
||||
$sections = $body['sections'] ?? [];
|
||||
|
||||
// Get sample post for dynamic placeholders
|
||||
$sample_post = null;
|
||||
if ($cpt && $cpt !== 'page') {
|
||||
$posts = get_posts([
|
||||
'post_type' => $cpt,
|
||||
'posts_per_page' => 1,
|
||||
'post_status' => 'publish',
|
||||
]);
|
||||
if (!empty($posts)) {
|
||||
$sample_post = $posts[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve placeholders if sample post exists
|
||||
$resolved_sections = $sections;
|
||||
if ($sample_post) {
|
||||
$post_data = PlaceholderRenderer::build_post_data($sample_post);
|
||||
$resolved_sections = [];
|
||||
foreach ($sections as $section) {
|
||||
$resolved_section = $section;
|
||||
$resolved_section['props'] = PageSSR::resolve_props($section['props'] ?? [], $post_data);
|
||||
$resolved_sections[] = $resolved_section;
|
||||
}
|
||||
}
|
||||
|
||||
$cpt_obj = get_post_type_object($cpt);
|
||||
$title = $cpt_obj ? $cpt_obj->labels->singular_name . ' Preview' : 'Template Preview';
|
||||
|
||||
// Render HTML
|
||||
$html = self::render_preview_html($title, $resolved_sections, 'template', $sample_post);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'html' => $html,
|
||||
'sample_post' => $sample_post ? [
|
||||
'id' => $sample_post->ID,
|
||||
'title' => $sample_post->post_title,
|
||||
] : null,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Render preview HTML document
|
||||
*/
|
||||
private static function render_preview_html($title, $sections, $type, $sample_post = null)
|
||||
{
|
||||
// Get site URL for assets
|
||||
$plugin_url = plugins_url('', dirname(dirname(__FILE__)));
|
||||
|
||||
// Start output buffering
|
||||
ob_start();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html <?php language_attributes(); ?>>
|
||||
<head>
|
||||
<meta charset="<?php bloginfo('charset'); ?>">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?php echo esc_html($title); ?> - Preview</title>
|
||||
<style>
|
||||
/* Reset and base styles */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1f2937;
|
||||
background: #fff;
|
||||
}
|
||||
img { max-width: 100%; height: auto; }
|
||||
|
||||
/* Section base */
|
||||
.wn-section { padding: 4rem 1rem; }
|
||||
.wn-container { max-width: 1200px; margin: 0 auto; }
|
||||
|
||||
/* Color schemes */
|
||||
.wn-scheme-default { background: #fff; color: #1f2937; }
|
||||
.wn-scheme-primary { background: #3b82f6; color: #fff; }
|
||||
.wn-scheme-secondary { background: #1f2937; color: #fff; }
|
||||
.wn-scheme-muted { background: #f3f4f6; color: #1f2937; }
|
||||
.wn-scheme-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Hero section */
|
||||
.wn-hero { text-align: center; padding: 6rem 1rem; }
|
||||
.wn-hero h1 { font-size: 2.5rem; font-weight: 800; margin-bottom: 1rem; }
|
||||
.wn-hero p { font-size: 1.25rem; opacity: 0.9; margin-bottom: 2rem; }
|
||||
.wn-hero .wn-btn {
|
||||
display: inline-block; padding: 0.75rem 1.5rem;
|
||||
background: currentColor; color: inherit;
|
||||
border-radius: 0.5rem; text-decoration: none;
|
||||
filter: invert(1); font-weight: 600;
|
||||
}
|
||||
|
||||
/* Content section */
|
||||
.wn-content { padding: 3rem 1rem; }
|
||||
.wn-content.wn-narrow .wn-container { max-width: 720px; }
|
||||
.wn-content.wn-medium .wn-container { max-width: 960px; }
|
||||
|
||||
/* Image + Text */
|
||||
.wn-image-text { display: flex; gap: 3rem; align-items: center; flex-wrap: wrap; }
|
||||
.wn-image-text .wn-image { flex: 1; min-width: 300px; }
|
||||
.wn-image-text .wn-text { flex: 1; min-width: 300px; }
|
||||
.wn-image-text.wn-image-right { flex-direction: row-reverse; }
|
||||
|
||||
/* Feature grid */
|
||||
.wn-features { display: grid; gap: 2rem; }
|
||||
.wn-features.wn-grid-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.wn-features.wn-grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.wn-features.wn-grid-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
@media (max-width: 768px) {
|
||||
.wn-features { grid-template-columns: 1fr; }
|
||||
}
|
||||
.wn-feature { text-align: center; padding: 1.5rem; }
|
||||
.wn-feature-icon { font-size: 2rem; margin-bottom: 1rem; }
|
||||
|
||||
/* CTA Banner */
|
||||
.wn-cta { text-align: center; padding: 4rem 1rem; }
|
||||
.wn-cta h2 { font-size: 2rem; margin-bottom: 1rem; }
|
||||
|
||||
/* Contact form */
|
||||
.wn-contact form { max-width: 500px; margin: 0 auto; }
|
||||
.wn-contact input, .wn-contact textarea {
|
||||
width: 100%; padding: 0.75rem; margin-bottom: 1rem;
|
||||
border: 1px solid #d1d5db; border-radius: 0.375rem;
|
||||
}
|
||||
.wn-contact button {
|
||||
width: 100%; padding: 0.75rem; background: #3b82f6;
|
||||
color: #fff; border: none; border-radius: 0.375rem;
|
||||
cursor: pointer; font-weight: 600;
|
||||
}
|
||||
|
||||
/* Preview indicator */
|
||||
.wn-preview-indicator {
|
||||
position: fixed; top: 0; left: 0; right: 0;
|
||||
background: #f59e0b; color: #000; text-align: center;
|
||||
padding: 0.5rem; font-size: 0.875rem; font-weight: 500;
|
||||
z-index: 9999;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wn-preview-indicator">
|
||||
🔍 Preview Mode <?php if ($sample_post): ?>(Using: <?php echo esc_html($sample_post->post_title); ?>)<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<main style="padding-top: 2.5rem;">
|
||||
<?php
|
||||
foreach ($sections as $section) {
|
||||
echo PageSSR::render_section($section, $sample_post ? PlaceholderRenderer::build_post_data($sample_post) : []);
|
||||
}
|
||||
|
||||
if (empty($sections)) {
|
||||
echo '<div style="text-align:center; padding:4rem; color:#9ca3af;">';
|
||||
echo '<p>No sections added yet.</p>';
|
||||
echo '<p>Add sections in the editor to see preview.</p>';
|
||||
echo '</div>';
|
||||
}
|
||||
?>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user