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

@@ -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();
}
}