feat: add preview endpoint and UI for Ghost post content with media URL rewriting

This commit is contained in:
Ender 2025-10-24 18:13:30 +02:00
parent 7378360104
commit b93e0ac9c1
2 changed files with 115 additions and 2 deletions

View File

@ -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<string>('');
const [activeStep, setActiveStep] = useState<number>(0);
const [previewHtml, setPreviewHtml] = useState<string>('');
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
const [previewError, setPreviewError] = useState<string | null>(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 && (
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="subtitle1">Publish</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>Preview below is based on current editor HTML.</Typography>
<Box sx={{ p: 1.5, border: '1px solid #eee', borderRadius: 1, bgcolor: '#fff' }} dangerouslySetInnerHTML={{ __html: draft }} />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Preview reflects Ghost media URL rewriting. Layout may differ from your Ghost theme.
</Typography>
<Stack direction="row" spacing={1}>
<Button size="small" variant="outlined" onClick={refreshPreview} disabled={previewLoading}>Refresh Preview</Button>
<Button size="small" variant="text" onClick={saveDraft}>Save Post</Button>
</Stack>
{previewLoading && (
<Box sx={{ p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}>Generating preview</Box>
)}
{previewError && (
<Alert severity="error">{previewError}</Alert>
)}
{!previewLoading && !previewError && (
<Box sx={{ p: 1.5, border: '1px solid #eee', borderRadius: 1, bgcolor: '#fff' }}
dangerouslySetInnerHTML={{ __html: previewHtml || draft }}
/>
)}
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Button variant="outlined" onClick={() => ghostPublish('draft')}>Save Draft to Ghost</Button>
<Button variant="contained" onClick={() => ghostPublish('published')}>Publish to Ghost</Button>

View File

@ -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<string> {
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<string> {
if (!htmlIn) return htmlIn;
const re = /<img\b[^>]*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;