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:
Dwindi Ramadhana
2026-01-12 12:08:03 +07:00
parent 8e53a9d65b
commit f4f7ff10f0
3 changed files with 363 additions and 11 deletions

View File

@@ -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 { __ } from '@/lib/i18n';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -13,7 +14,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch'; 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 { interface Section {
id: string; id: string;
@@ -40,6 +41,7 @@ interface AvailableSource {
interface PageSettingsProps { interface PageSettingsProps {
page: PageItem | null; page: PageItem | null;
section: Section | null; section: Section | null;
sections: Section[]; // All sections for preview
onSectionUpdate: (section: Section) => void; onSectionUpdate: (section: Section) => void;
isTemplate?: boolean; isTemplate?: boolean;
availableSources?: AvailableSource[]; availableSources?: AvailableSource[];
@@ -111,11 +113,84 @@ const COLOR_SCHEMES = [
export function PageSettings({ export function PageSettings({
page, page,
section, section,
sections,
onSectionUpdate, onSectionUpdate,
isTemplate = false, isTemplate = false,
availableSources = [], availableSources = [],
}: PageSettingsProps) { }: 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 // Update section prop
const updateProp = (name: string, value: any, isDynamic?: boolean) => { const updateProp = (name: string, value: any, isDynamic?: boolean) => {
@@ -310,16 +385,30 @@ export function PageSettings({
</> </>
)} )}
{/* Preview Toggle */} {/* Preview Panel */}
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2"> <CardTitle className="text-sm flex items-center justify-between">
<Eye className="w-4 h-4" /> <span className="flex items-center gap-2">
{__('Preview')} <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> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex gap-2"> {/* Preview Mode Toggle */}
<div className="flex gap-2 mb-3">
<Button <Button
variant={previewMode === 'desktop' ? 'default' : 'outline'} variant={previewMode === 'desktop' ? 'default' : 'outline'}
size="sm" size="sm"
@@ -337,9 +426,57 @@ export function PageSettings({
{__('Mobile')} {__('Mobile')}
</Button> </Button>
</div> </div>
<p className="text-xs text-gray-500 mt-3">
{__('Live preview will be available after saving.')} {/* Preview Toggle */}
</p> {!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> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -235,6 +235,7 @@ export default function AppearancePages() {
<PageSettings <PageSettings
page={selectedPage} page={selectedPage}
section={selectedSection} section={selectedSection}
sections={structure?.sections || []}
onSectionUpdate={handleSectionUpdate} onSectionUpdate={handleSectionUpdate}
isTemplate={selectedPage?.type === 'template'} isTemplate={selectedPage?.type === 'template'}
availableSources={pageData?.available_sources || []} availableSources={pageData?.available_sources || []}

View File

@@ -68,6 +68,20 @@ class PagesController
'callback' => [__CLASS__, 'create_page'], 'callback' => [__CLASS__, 'create_page'],
'permission_callback' => [__CLASS__, 'check_admin_permission'], '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; 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();
}
} }