feat: add preview endpoint and UI for Ghost post content with media URL rewriting
This commit is contained in:
parent
7378360104
commit
b93e0ac9c1
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user