"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)
? ` `
: ` `;
})
.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
? `${escapeHtml(categoryLabel(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} }
setTheme(theme === "light" ? "dark" : "light")}>
{theme === "light" ? "☾ dark" : "☀ light"}
);
}