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 || []}
|
||||
|
||||
Reference in New Issue
Block a user