From b93e0ac9c1cfd2d55e727c62c8d2adf9c421e2fe Mon Sep 17 00:00:00 2001 From: Ender Date: Fri, 24 Oct 2025 18:13:30 +0200 Subject: [PATCH] feat: add preview endpoint and UI for Ghost post content with media URL rewriting --- apps/admin/src/components/EditorShell.tsx | 52 +++++++++++++++++- apps/api/src/ghost.ts | 65 +++++++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/apps/admin/src/components/EditorShell.tsx b/apps/admin/src/components/EditorShell.tsx index da5d2e9..bcfd3b6 100644 --- a/apps/admin/src/components/EditorShell.tsx +++ b/apps/admin/src/components/EditorShell.tsx @@ -17,6 +17,9 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog const [toast, setToast] = useState<{ open: boolean; message: string; severity: 'success' | 'error' } | null>(null); const [promptText, setPromptText] = useState(''); const [activeStep, setActiveStep] = useState(0); + const [previewHtml, setPreviewHtml] = useState(''); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(null); useEffect(() => { const savedId = initialPostId || localStorage.getItem('voxblog_draft_id'); @@ -104,6 +107,35 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog } }; + const refreshPreview = async () => { + try { + setPreviewLoading(true); + setPreviewError(null); + const res = await fetch('/api/ghost/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + html: draft, + feature_image: meta.featureImage || undefined, + }), + }); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + setPreviewHtml(data.html || ''); + } catch (e: any) { + setPreviewError(e?.message || 'Failed to generate preview'); + } finally { + setPreviewLoading(false); + } + }; + + useEffect(() => { + if (activeStep === 5) { + // Generate preview when entering Publish step + refreshPreview(); + } + }, [activeStep]); + // No inline post switching here; selection happens on Posts page return ( @@ -240,8 +272,24 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog {activeStep === 5 && ( Publish - Preview below is based on current editor HTML. - + + Preview reflects Ghost media URL rewriting. Layout may differ from your Ghost theme. + + + + + + {previewLoading && ( + Generating preview… + )} + {previewError && ( + {previewError} + )} + {!previewLoading && !previewError && ( + + )} diff --git a/apps/api/src/ghost.ts b/apps/api/src/ghost.ts index 0360b6a..62c6094 100644 --- a/apps/api/src/ghost.ts +++ b/apps/api/src/ghost.ts @@ -195,4 +195,69 @@ router.post('/post', async (req, res) => { } }); +// Preview endpoint: rewrite media URLs without copying, to render a faithful content preview +router.post('/preview', async (req, res) => { + try { + const { html, feature_image } = req.body as { html: string; feature_image?: string }; + + const replacePairs: Array<{ from: string; to: string }> = []; + const origin = `${req.protocol}://${req.get('host')}`; + + async function rewriteInternalMediaUrlPreview(src: string, _bucketFallback: string): Promise { + try { + if (!src) return src; + // If this references our media proxy, ensure it points to the API origin + if (src.startsWith('/api/media/obj')) { + const out = `${origin}${src}`; + if (out !== src) replacePairs.push({ from: src, to: out }); + return out; + } + if (/^https?:\/\//i.test(src)) { + try { + const uAbs = new URL(src); + if (uAbs.pathname === '/api/media/obj') { + const out = `${origin}${uAbs.pathname}${uAbs.search}`; + if (out !== src) replacePairs.push({ from: src, to: out }); + return out; + } + return src; + } catch { + return src; + } + } + return src; + } catch { + return src; + } + } + + async function rewriteHtmlImagesToPublicPreview(htmlIn: string, bucketFallback: string): Promise { + if (!htmlIn) return htmlIn; + const re = /]*src=["']([^"']+)["'][^>]*>/gi; + const matches = [...htmlIn.matchAll(re)]; + if (matches.length === 0) return htmlIn; + let out = ''; + let last = 0; + for (const m of matches) { + const idx = m.index || 0; + const full = m[0]; + const srcAttr = m[1]; + const newSrc = await rewriteInternalMediaUrlPreview(srcAttr, process.env.S3_BUCKET || ''); + out += htmlIn.slice(last, idx) + full.replace(srcAttr, newSrc); + last = idx + full.length; + } + out += htmlIn.slice(last); + return out; + } + + const rewrittenFeatureImage = await rewriteInternalMediaUrlPreview(feature_image || '', ''); + const rewrittenHtml = await rewriteHtmlImagesToPublicPreview(html, ''); + + return res.json({ html: rewrittenHtml, feature_image: rewrittenFeatureImage, replacements: replacePairs }); + } catch (err: any) { + console.error('Ghost preview failed:', err); + return res.status(500).json({ error: 'Ghost preview failed', detail: err?.message || String(err) }); + } +}); + export default router;