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 [toast, setToast] = useState<{ open: boolean; message: string; severity: 'success' | 'error' } | null>(null);
|
||||||
const [promptText, setPromptText] = useState<string>('');
|
const [promptText, setPromptText] = useState<string>('');
|
||||||
const [activeStep, setActiveStep] = useState<number>(0);
|
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(() => {
|
useEffect(() => {
|
||||||
const savedId = initialPostId || localStorage.getItem('voxblog_draft_id');
|
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
|
// No inline post switching here; selection happens on Posts page
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -240,8 +272,24 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
|
|||||||
{activeStep === 5 && (
|
{activeStep === 5 && (
|
||||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||||
<Typography variant="subtitle1">Publish</Typography>
|
<Typography variant="subtitle1">Publish</Typography>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>Preview below is based on current editor HTML.</Typography>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
<Box sx={{ p: 1.5, border: '1px solid #eee', borderRadius: 1, bgcolor: '#fff' }} dangerouslySetInnerHTML={{ __html: draft }} />
|
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 }}>
|
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||||
<Button variant="outlined" onClick={() => ghostPublish('draft')}>Save Draft to Ghost</Button>
|
<Button variant="outlined" onClick={() => ghostPublish('draft')}>Save Draft to Ghost</Button>
|
||||||
<Button variant="contained" onClick={() => ghostPublish('published')}>Publish 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;
|
export default router;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user