132 lines
4.9 KiB
TypeScript
132 lines
4.9 KiB
TypeScript
"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 = `<!doctype html>
|
|
<html data-theme="light">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<base href="/site/">
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=JetBrains+Mono:wght@400;500;700&display=swap">
|
|
<style>:root { --mono: "JetBrains Mono", monospace; --sans: "DM Sans", sans-serif; }</style>
|
|
<link rel="stylesheet" href="/site/app/globals.css">
|
|
<link rel="stylesheet" href="/site/styles/markdown-container.css">
|
|
<link rel="stylesheet" href="/site/styles/blog.css">
|
|
<style>
|
|
body { max-width: 880px; margin: 0 auto; padding: 8px 24px 48px; }
|
|
.bm-gallery { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 2em; }
|
|
.bm-gallery img, .bm-gallery video { height: 120px; border-radius: 4px; border: 1px solid var(--border); }
|
|
.bm-empty { color: var(--muted); font-family: var(--mono); font-size: 12px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="blog-markdown-container" class="markdown-container"></div>
|
|
</body>
|
|
</html>`;
|
|
|
|
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)
|
|
? `<video src="${escapeHtml(src)}" muted></video>`
|
|
: `<img src="${escapeHtml(src)}" alt="${alt}" title="${alt}">`;
|
|
})
|
|
.join("");
|
|
return `<section class="project-details-gallery"><h2>Gallery</h2><div class="bm-gallery">${items}</div></section>`;
|
|
}
|
|
|
|
export default function PreviewFrame({ markdown, category, images, title }: Props) {
|
|
const frameRef = useRef<HTMLIFrameElement>(null);
|
|
const [html, setHtml] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(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
|
|
? `<a class="blog-category-chip is-active blog-post-category-chip">${escapeHtml(categoryLabel(category))}</a>`
|
|
: "";
|
|
const body = html.trim() ? `<main>${html}</main>` : `<p class="bm-empty">Nothing to preview yet.</p>`;
|
|
// Same structure as portfolio/app/blog/[slug]/page.tsx.
|
|
container.innerHTML = chip + body + galleryHtml(images, title);
|
|
}, [frameReady, html, category, images, title, theme]);
|
|
|
|
return (
|
|
<div className="pane">
|
|
<div className="pane-head">
|
|
<span>preview</span>
|
|
{error && <span className="badge err">{error}</span>}
|
|
<span className="spacer" />
|
|
<button type="button" onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
|
|
{theme === "light" ? "☾ dark" : "☀ light"}
|
|
</button>
|
|
</div>
|
|
<div className="pane-body">
|
|
<iframe
|
|
ref={frameRef}
|
|
className="preview-frame"
|
|
title="Post preview"
|
|
srcDoc={SHELL}
|
|
onLoad={() => setFrameReady(true)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|