initial commit

This commit is contained in:
Hunter
2026-11-01 13:23:44 -05:00
commit e03ea4f770
53 changed files with 10802 additions and 0 deletions

131
components/PreviewFrame.tsx Normal file
View 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>
);
}