initial commit
This commit is contained in:
131
components/PreviewFrame.tsx
Normal file
131
components/PreviewFrame.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user