"use client"; import { useEffect, useRef, useState } from "react"; import { api } from "@/lib/client"; import { categoryLabel } from "@/lib/validate"; import { useSystemDark } from "@/lib/useSystemDark"; import { imageDescription, imageSrc, type GalleryImage } from "@/lib/types"; /* Live preview of a post page. The HTML comes from the site's own remark * pipeline (/api/preview) and is painted inside an iframe that loads the * site's real stylesheets (served from the checkout via /site/...), so the * site's CSS can't leak into the manager and the preview matches the live * page. The iframe shell is created once; updates swap its content in place * so scroll position survives typing. */ const SHELL = `
`; type Props = { markdown: string; category: string; images: GalleryImage[]; title: string; }; function escapeHtml(s: string): string { return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); } function galleryHtml(images: GalleryImage[], title: string): string { if (!images.length) return ""; const items = images .map((img) => { const src = "/" + imageSrc(img); const alt = escapeHtml(imageDescription(img) ?? title); return /\.mp4$/i.test(src) ? `` : `${alt}`; }) .join(""); return ``; } export default function PreviewFrame({ markdown, category, images, title }: Props) { const frameRef = useRef(null); const [html, setHtml] = useState(null); const [error, setError] = useState(null); const [frameReady, setFrameReady] = useState(false); // Follows the OS theme until toggled by hand. const systemDark = useSystemDark(); const [themeOverride, setTheme] = useState<"light" | "dark" | null>(null); const theme = themeOverride ?? (systemDark ? "dark" : "light"); // Debounced render through the site's pipeline. useEffect(() => { const controller = new AbortController(); const timer = setTimeout(async () => { try { const res = await api<{ html: string }>("/api/preview", { method: "POST", json: { markdown }, signal: controller.signal, }); setHtml(res.html); setError(null); } catch (err) { if (!controller.signal.aborted) setError((err as Error).message); } }, 250); return () => { clearTimeout(timer); controller.abort(); }; }, [markdown]); useEffect(() => { const doc = frameRef.current?.contentDocument; if (!frameReady || !doc) return; doc.documentElement.dataset.theme = theme; const container = doc.getElementById("blog-markdown-container"); if (!container || html === null) return; const chip = category ? `` : ""; const body = html.trim() ? `
${html}
` : `

Nothing to preview yet.

`; // Same structure as portfolio/app/blog/[slug]/page.tsx. container.innerHTML = chip + body + galleryHtml(images, title); }, [frameReady, html, category, images, title, theme]); return (
preview {error && {error}}