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

216
components/ImagePanel.tsx Normal file
View File

@@ -0,0 +1,216 @@
"use client";
import { useRef, useState } from "react";
import { api } from "@/lib/client";
import { imageDescription, imageSrc, type GalleryImage, type ImageFile } from "@/lib/types";
/* Two related things in one panel:
* - files: what's in public/img/blog/<slug>/ (upload / delete / insert)
* - gallery: the post's images[] (ordered; the first is the card cover).
* Uploading and deleting files happens immediately; gallery edits are part
* of the post and are saved with it. */
type Props = {
/** The saved slug; uploads are disabled until the post exists. */
slug: string | null;
files: ImageFile[];
onFilesChange: (files: ImageFile[]) => void;
gallery: GalleryImage[];
onGalleryChange: (gallery: GalleryImage[]) => void;
onInsert: (file: ImageFile) => void;
};
function formatSize(bytes: number): string {
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(bytes / 1024)} KB`;
}
function withDescription(src: string, description: string): GalleryImage {
return description.trim() ? { src, description } : src;
}
export default function ImagePanel({ slug, files, onFilesChange, gallery, onGalleryChange, onInsert }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const [over, setOver] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const galleryIndex = (src: string) => gallery.findIndex((g) => imageSrc(g) === src);
async function upload(list: FileList | File[]) {
if (!slug || !list.length) return;
const form = new FormData();
for (const f of Array.from(list)) form.append("file", f);
setBusy(true);
setError(null);
try {
const res = await api<{ files: ImageFile[] }>(`/api/images/${slug}`, { method: "POST", body: form });
onFilesChange(res.files);
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
async function remove(file: ImageFile) {
if (!slug || !window.confirm(`Delete ${file.name} from disk?`)) return;
try {
const res = await api<{ files: ImageFile[] }>(
`/api/images/${slug}?name=${encodeURIComponent(file.name)}`,
{ method: "DELETE" }
);
onFilesChange(res.files);
if (galleryIndex(file.src) >= 0) onGalleryChange(gallery.filter((g) => imageSrc(g) !== file.src));
} catch (err) {
setError((err as Error).message);
}
}
function move(index: number, delta: number) {
const next = [...gallery];
const [item] = next.splice(index, 1);
next.splice(index + delta, 0, item);
onGalleryChange(next);
}
// Gallery entries pointing outside this post's folder (e.g. hand-added
// paths) still need to be manageable, so list them too.
const fileSrcs = new Set(files.map((f) => f.src));
const external = gallery.filter((g) => !fileSrcs.has(imageSrc(g)));
return (
<section className="images">
<h2>Images</h2>
{error && <div className="notice error">{error}</div>}
{gallery.length > 0 && (
<>
<p className="muted small">
Gallery (shown below the post; the first image is also the card cover). Edits here are saved with the
post.
</p>
<table className="list" style={{ marginBottom: 20 }}>
<tbody>
{gallery.map((img, i) => (
<tr key={imageSrc(img)}>
<td className="mono small" style={{ width: 30 }}>
{i === 0 ? "cover" : i + 1}
</td>
<td style={{ width: 80 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={"/" + imageSrc(img)} alt="" style={{ height: 40, borderRadius: 4 }} />
</td>
<td className="mono small">{imageSrc(img)}</td>
<td>
<input
placeholder="description (alt text)"
value={imageDescription(img) ?? ""}
onChange={(e) =>
onGalleryChange(gallery.map((g, j) => (j === i ? withDescription(imageSrc(g), e.target.value) : g)))
}
/>
</td>
<td className="actions">
<button type="button" className="ghost" disabled={i === 0} onClick={() => move(i, -1)} title="Move up">
↑
</button>
<button
type="button"
className="ghost"
disabled={i === gallery.length - 1}
onClick={() => move(i, 1)}
title="Move down"
>
↓
</button>
<button type="button" onClick={() => onGalleryChange(gallery.filter((_, j) => j !== i))}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
{external.length > 0 && (
<p className="muted small">
{external.length} gallery image(s) live outside public/img/blog/{slug ?? "<slug>"}/.
</p>
)}
</>
)}
{!slug ? (
<div className="image-drop">Save the post once to enable image uploads.</div>
) : (
<div
className={`image-drop${over ? " over" : ""}`}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
e.preventDefault();
setOver(false);
upload(e.dataTransfer.files);
}}
>
{busy ? "Uploading…" : `Drop images here or click to upload → public/img/blog/${slug}/`}
<input
ref={inputRef}
type="file"
multiple
accept=".png,.jpg,.jpeg,.gif,.webp,.svg,.mp4"
hidden
onChange={(e) => {
if (e.target.files) upload(e.target.files);
e.target.value = "";
}}
/>
</div>
)}
<div className="image-grid">
{files.map((file) => {
const inGallery = galleryIndex(file.src) >= 0;
return (
<div key={file.name} className={`image-card${inGallery ? " in-gallery" : ""}`}>
<div className="thumb">
{/\.mp4$/i.test(file.name) ? (
<video src={"/" + file.src} muted />
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={"/" + file.src} alt={file.name} />
)}
</div>
<div className="body">
<div className="name">
{file.name} <span className="muted">· {formatSize(file.size)}</span>
</div>
<div className="row">
<button type="button" onClick={() => onInsert(file)} title="Insert at cursor in the body">
Insert
</button>
<button
type="button"
onClick={() =>
onGalleryChange(
inGallery ? gallery.filter((g) => imageSrc(g) !== file.src) : [...gallery, file.src]
)
}
>
{inGallery ? "− Gallery" : "+ Gallery"}
</button>
<button type="button" className="danger" onClick={() => remove(file)}>
Delete
</button>
</div>
</div>
</div>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,174 @@
"use client";
import { useMemo, useState } from "react";
import CodeMirror, { EditorSelection, EditorView, keymap } from "@uiw/react-codemirror";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { languages } from "@codemirror/language-data";
import { useSystemDark } from "@/lib/useSystemDark";
/* Markdown source editor with a formatting toolbar. Source (not WYSIWYG) on
* purpose: posts embed raw HTML blocks that rich-text editors would mangle.
* The live preview beside it is what makes it visual. */
type Props = {
value: string;
onChange: (value: string) => void;
/** Receives the EditorView so the parent can insert text (e.g. images). */
onView?: (view: EditorView | null) => void;
};
/** Wrap each selection in `before`/`after`, or unwrap if already wrapped. */
export function wrap(view: EditorView, before: string, after = before, placeholder = "text") {
view.dispatch(
view.state.changeByRange((range) => {
const doc = view.state.doc;
const outerFrom = range.from - before.length;
const outerTo = range.to + after.length;
const isWrapped =
outerFrom >= 0 &&
outerTo <= doc.length &&
doc.sliceString(outerFrom, range.from) === before &&
doc.sliceString(range.to, outerTo) === after;
if (isWrapped) {
return {
changes: [
{ from: outerFrom, to: range.from, insert: "" },
{ from: range.to, to: outerTo, insert: "" },
],
range: EditorSelection.range(outerFrom, range.to - before.length),
};
}
const text = range.empty ? placeholder : doc.sliceString(range.from, range.to);
return {
changes: { from: range.from, to: range.to, insert: before + text + after },
range: EditorSelection.range(range.from + before.length, range.from + before.length + text.length),
};
})
);
view.focus();
}
/** Toggle a prefix on every line touched by the selection ("# ", "> ", "- "...). */
export function prefixLines(view: EditorView, prefix: string | ((i: number) => string)) {
const { state } = view;
const changes: { from: number; to?: number; insert: string }[] = [];
const seen = new Set<number>();
for (const range of state.selection.ranges) {
const first = state.doc.lineAt(range.from).number;
const last = state.doc.lineAt(range.to).number;
for (let n = first, i = 0; n <= last; n++, i++) {
if (seen.has(n)) continue;
seen.add(n);
const line = state.doc.line(n);
const p = typeof prefix === "function" ? prefix(i) : prefix;
// Strip any existing heading/quote/list marker so prefixes replace each other.
const existing = /^(#{1,6} |> |- |\d+\. )/.exec(line.text)?.[0] ?? "";
if (existing === p) changes.push({ from: line.from, to: line.from + existing.length, insert: "" });
else changes.push({ from: line.from, to: line.from + existing.length, insert: p });
}
}
view.dispatch({ changes });
view.focus();
}
/** Replace the selection with `text` (on its own lines when `block`). */
export function insertText(view: EditorView, text: string, block = false) {
const { state } = view;
const range = state.selection.main;
let insert = text;
if (block) {
const before = state.doc.sliceString(Math.max(0, range.from - 2), range.from);
const after = state.doc.sliceString(range.to, range.to + 1);
if (range.from > 0 && !before.endsWith("\n\n")) insert = (before.endsWith("\n") ? "\n" : "\n\n") + insert;
if (after !== "\n") insert += "\n";
}
view.dispatch({
changes: { from: range.from, to: range.to, insert },
selection: EditorSelection.cursor(range.from + insert.length),
});
view.focus();
}
function link(view: EditorView) {
const url = window.prompt("Link URL", "https://");
if (!url) return;
wrap(view, "[", `](${url})`, "link text");
}
const BLOCKQUOTE_HTML = "<blockquote>\nQuote text\n</blockquote>";
export default function MarkdownEditor({ value, onChange, onView }: Props) {
const [view, setView] = useState<EditorView | null>(null);
const dark = useSystemDark();
const extensions = useMemo(
() => [
markdown({ base: markdownLanguage, codeLanguages: languages }),
EditorView.lineWrapping,
keymap.of([
{ key: "Mod-b", run: (v) => (wrap(v, "**"), true) },
{ key: "Mod-i", run: (v) => (wrap(v, "*"), true) },
{ key: "Mod-k", run: (v) => (link(v), true) },
]),
],
[]
);
const tools: ({ label: string; title: string; run: (v: EditorView) => void } | "sep")[] = [
{ label: "H1", title: "Heading 1", run: (v) => prefixLines(v, "# ") },
{ label: "H2", title: "Heading 2", run: (v) => prefixLines(v, "## ") },
{ label: "H3", title: "Heading 3", run: (v) => prefixLines(v, "### ") },
"sep",
{ label: "B", title: "Bold (Ctrl+B)", run: (v) => wrap(v, "**") },
{ label: "I", title: "Italic (Ctrl+I)", run: (v) => wrap(v, "*") },
{ label: "</>", title: "Inline code", run: (v) => wrap(v, "`", "`", "code") },
{ label: "link", title: "Link (Ctrl+K)", run: link },
"sep",
{ label: "• list", title: "Bullet list", run: (v) => prefixLines(v, "- ") },
{ label: "1. list", title: "Numbered list", run: (v) => prefixLines(v, (i) => `${i + 1}. `) },
{ label: "> quote", title: "Markdown quote", run: (v) => prefixLines(v, "> ") },
{ label: "<quote>", title: "HTML blockquote (the site's raised style)", run: (v) => insertText(v, BLOCKQUOTE_HTML, true) },
{ label: "```", title: "Code block", run: (v) => wrap(v, "```\n", "\n```", "code") },
{ label: "—", title: "Horizontal rule", run: (v) => insertText(v, "---", true) },
];
return (
<div className="pane">
<div className="pane-head">
{tools.map((tool, i) =>
tool === "sep" ? (
<span key={i} className="tb-sep" />
) : (
<button
key={tool.label}
type="button"
title={tool.title}
disabled={!view}
onMouseDown={(e) => e.preventDefault()}
onClick={() => view && tool.run(view)}
>
{tool.label}
</button>
)
)}
<span className="spacer" />
<span>markdown</span>
</div>
<div className="pane-body">
<CodeMirror
value={value}
onChange={onChange}
extensions={extensions}
theme={dark ? "dark" : "light"}
height="100%"
style={{ position: "absolute", inset: 0 }}
basicSetup={{ foldGutter: false, highlightActiveLine: false }}
onCreateEditor={(v) => {
setView(v);
onView?.(v);
}}
/>
</div>
</div>
);
}

257
components/PostEditor.tsx Normal file
View File

@@ -0,0 +1,257 @@
"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { EditorView } from "@uiw/react-codemirror";
import { api, ApiError } from "@/lib/client";
import { categoryLabel, slugify, todayIso, validatePost } from "@/lib/validate";
import type { BlogPost, GalleryImage, ImageFile, PostInput } from "@/lib/types";
import TagInput from "./TagInput";
import PreviewFrame from "./PreviewFrame";
import ImagePanel from "./ImagePanel";
import { insertText } from "./MarkdownEditor";
// CodeMirror touches the DOM on load; render it client-side only.
const MarkdownEditor = dynamic(() => import("./MarkdownEditor"), {
ssr: false,
loading: () => <div className="pane" />,
});
type Props = {
/** Absent for a new post. */
post?: BlogPost;
revision: string;
files?: ImageFile[];
categories: string[];
tags: string[];
};
type Form = PostInput & { images: GalleryImage[] };
function toForm(post?: BlogPost): Form {
return {
slug: post?.slug ?? "",
title: post?.title ?? "",
description: post?.description ?? "",
date: post?.date ?? todayIso(),
category: post?.category ?? "",
tags: post?.tags ?? [],
images: post?.images ?? [],
details: post?.details ?? "",
};
}
export default function PostEditor({ post, revision: initialRevision, files: initialFiles, categories, tags }: Props) {
const router = useRouter();
const categoryListId = useId();
const viewRef = useRef<EditorView | null>(null);
const [form, setForm] = useState<Form>(() => toForm(post));
const [baseline, setBaseline] = useState(() => JSON.stringify(toForm(post)));
const [savedSlug, setSavedSlug] = useState<string | null>(post?.slug ?? null);
const [slugTouched, setSlugTouched] = useState(Boolean(post));
const [revision, setRevision] = useState(initialRevision);
const [files, setFiles] = useState<ImageFile[]>(initialFiles ?? []);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
const [conflict, setConflict] = useState(false);
const dirty = JSON.stringify(form) !== baseline;
const update = <K extends keyof Form>(key: K, value: Form[K]) => setForm((f) => ({ ...f, [key]: value }));
function setTitle(title: string) {
setForm((f) => ({ ...f, title, slug: slugTouched ? f.slug : slugify(title) }));
}
const save = useCallback(
async (opts: { close?: boolean; force?: boolean } = {}) => {
const { post: input, errors } = validatePost(form);
if (!input) {
setMessage({ kind: "error", text: errors.join("; ") });
return;
}
setSaving(true);
setMessage(null);
try {
const body = { post: input, revision: opts.force ? undefined : revision };
const res = savedSlug
? await api<{ post: BlogPost; revision: string }>(`/api/posts/${savedSlug}`, { method: "PUT", json: body })
: await api<{ post: BlogPost; revision: string }>("/api/posts", { method: "POST", json: body });
setRevision(res.revision);
setBaseline(JSON.stringify(form));
setConflict(false);
const slugChanged = res.post.slug !== savedSlug;
setSavedSlug(res.post.slug);
setMessage({ kind: "ok", text: `Saved ${new Date().toLocaleTimeString()}` });
if (opts.close) router.push("/");
else if (slugChanged) router.replace(`/posts/${res.post.slug}`);
router.refresh();
} catch (err) {
if (err instanceof ApiError && err.status === 409 && /changed on disk/.test(err.message)) setConflict(true);
else setMessage({ kind: "error", text: (err as Error).message });
} finally {
setSaving(false);
}
},
[form, revision, savedSlug, router]
);
// Ctrl/Cmd+S saves.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
if (!saving) save();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [save, saving]);
// Warn before losing unsaved edits: tab close/reload, and in-app links.
useEffect(() => {
if (!dirty) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => e.preventDefault();
const onClick = (e: MouseEvent) => {
const a = (e.target as HTMLElement).closest("a[href]");
if (a && !a.closest(".preview-frame") && !window.confirm("Discard unsaved changes?")) {
e.preventDefault();
e.stopPropagation();
}
};
window.addEventListener("beforeunload", onBeforeUnload);
document.addEventListener("click", onClick, true);
return () => {
window.removeEventListener("beforeunload", onBeforeUnload);
document.removeEventListener("click", onClick, true);
};
}, [dirty]);
function insertImage(file: ImageFile) {
const view = viewRef.current;
if (!view) return;
const src = "/" + file.src;
const snippet = /\.mp4$/i.test(file.name)
? `<video src="${src}" controls></video>`
: `![${file.name.replace(/\.[^.]+$/, "").replace(/[-_]+/g, " ")}](${src})`;
insertText(view, snippet, true);
}
const isNew = !savedSlug;
return (
<main>
<div className="editor-head">
<h1>{isNew ? "New post" : form.title || savedSlug}</h1>
{dirty && <span className="badge warn">unsaved</span>}
<Link href="/" className="button">
Back
</Link>
<button disabled={saving} onClick={() => save()}>
{saving ? "Saving…" : "Save"}
</button>
<button className="primary" disabled={saving} onClick={() => save({ close: true })}>
Save &amp; close
</button>
</div>
{conflict && (
<div className="notice warn">
blog-posts.json changed on disk since this page loaded (another edit, or a git pull).
<button onClick={() => window.location.reload()}>Reload (discard my edits)</button>
<button className="danger" onClick={() => save({ force: true })}>
Save anyway (overwrite this post)
</button>
</div>
)}
{message && <div className={`notice ${message.kind}`}>{message.text}</div>}
<div className="meta-grid">
<label className="field">
<span>title</span>
<input value={form.title} onChange={(e) => setTitle(e.target.value)} placeholder="Post title" />
</label>
<label className="field">
<span>slug</span>
<input
className="mono"
value={form.slug}
onChange={(e) => {
setSlugTouched(true);
update("slug", e.target.value);
}}
onBlur={() => update("slug", slugify(form.slug))}
placeholder="post-slug"
/>
<span className="field-hint">
/blog/{form.slug || "…"}
{savedSlug && form.slug !== savedSlug && " · renaming moves the image folder"}
</span>
</label>
<label className="field">
<span>date</span>
<input type="date" value={form.date} onChange={(e) => update("date", e.target.value)} />
</label>
<label className="field span-2">
<span>description</span>
<textarea
rows={2}
value={form.description}
onChange={(e) => update("description", e.target.value)}
placeholder="1–2 sentence excerpt shown on the post card"
/>
<span className="field-hint">{form.description.length} characters</span>
</label>
<label className="field">
<span>category</span>
<input
list={categoryListId}
value={form.category}
onChange={(e) => update("category", e.target.value)}
onBlur={() => update("category", slugify(form.category))}
placeholder="pick or type a new one"
/>
<datalist id={categoryListId}>
{categories.map((c) => (
<option key={c} value={c}>
{categoryLabel(c)}
</option>
))}
</datalist>
<span className="field-hint">
{form.category
? `shown as “${categoryLabel(form.category)}”${categories.includes(form.category) ? "" : " · new category"}`
: "one per post"}
</span>
</label>
<div className="field span-all">
<span className="mono small muted">tags</span>
<TagInput value={form.tags} onChange={(t) => update("tags", t)} suggestions={tags} />
</div>
</div>
<div className="split">
<MarkdownEditor
value={form.details}
onChange={(v) => update("details", v)}
onView={(v) => (viewRef.current = v)}
/>
<PreviewFrame markdown={form.details} category={form.category} images={form.images} title={form.title} />
</div>
<ImagePanel
slug={savedSlug && savedSlug === form.slug ? savedSlug : null}
files={files}
onFilesChange={setFiles}
gallery={form.images}
onGalleryChange={(g) => update("images", g)}
onInsert={insertImage}
/>
</main>
);
}

127
components/PostTable.tsx Normal file
View File

@@ -0,0 +1,127 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/client";
import { categoryLabel } from "@/lib/validate";
import type { BlogPost } from "@/lib/types";
type Props = { posts: BlogPost[]; revision: string; categories: string[] };
export default function PostTable({ posts, revision, categories }: Props) {
const router = useRouter();
const [query, setQuery] = useState("");
const [category, setCategory] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return posts.filter(
(p) =>
(!category || p.category === category) &&
(!q || [p.title, p.slug, p.description, ...p.tags].some((s) => s.toLowerCase().includes(q)))
);
}, [posts, query, category]);
async function remove(post: BlogPost) {
const typed = window.prompt(`Delete "${post.title}"?\n\nType the slug (${post.slug}) to confirm.`);
if (typed === null) return;
if (typed.trim() !== post.slug) {
setError("Slug didn't match; nothing was deleted.");
return;
}
const withImages = window.confirm(
`Also delete the image folder public/img/blog/${post.slug}/?\n\nOK = delete images too, Cancel = keep them.`
);
setBusy(post.slug);
setError(null);
try {
const params = new URLSearchParams({ revision, ...(withImages ? { images: "1" } : {}) });
await api(`/api/posts/${post.slug}?${params}`, { method: "DELETE" });
router.refresh();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(null);
}
}
return (
<>
<div className="toolbar-row">
<h1 style={{ margin: 0, flex: 1 }}>Posts</h1>
<input placeholder="search title, slug, tags…" value={query} onChange={(e) => setQuery(e.target.value)} />
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="">all categories</option>
{categories.map((c) => (
<option key={c} value={c}>
{categoryLabel(c)}
</option>
))}
</select>
<Link href="/posts/new" className="button primary">
+ New post
</Link>
</div>
{error && <div className="notice error">{error}</div>}
<table className="list">
<thead>
<tr>
<th>title</th>
<th>date</th>
<th>category</th>
<th>tags</th>
<th>images</th>
<th />
</tr>
</thead>
<tbody>
{visible.map((post) => (
<tr key={post.slug}>
<td>
<Link href={`/posts/${post.slug}`} className="title-cell">
{post.title}
</Link>
<div className="mono small muted">/blog/{post.slug}</div>
</td>
<td className="mono small">{post.date}</td>
<td>
<span className="chip">{post.category}</span>
</td>
<td>
{post.tags.map((t) => (
<span key={t} className="chip">
{t}
</span>
))}
</td>
<td className="mono small">{post.images?.length ?? 0}</td>
<td className="actions">
<Link href={`/posts/${post.slug}`} className="button">
Edit
</Link>
<button className="danger" disabled={busy === post.slug} onClick={() => remove(post)}>
Delete
</button>
</td>
</tr>
))}
{visible.length === 0 && (
<tr>
<td colSpan={6} className="muted">
{posts.length ? "No posts match." : "No posts yet."}
</td>
</tr>
)}
</tbody>
</table>
<p className="muted small">
{visible.length} of {posts.length} posts
</p>
</>
);
}

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>
);
}

110
components/PublishPanel.tsx Normal file
View File

@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/client";
import type { GitStatus } from "@/lib/git";
import type { PublishStep } from "@/lib/publish";
type Config = { push: boolean; pushAuth: string; deploy: boolean; deployUrl: string | null };
function defaultMessage(status: GitStatus): string {
const n = status.changes.length;
return n === 1 && status.changes[0].path.endsWith("blog-posts.json") ? "blog: update posts" : `blog: update posts (${n} files)`;
}
export default function PublishPanel({ status: initial, config }: { status: GitStatus; config: Config }) {
const router = useRouter();
const [status, setStatus] = useState(initial);
const [message, setMessage] = useState(() => defaultMessage(initial));
const [running, setRunning] = useState(false);
const [result, setResult] = useState<{ ok: boolean; steps: PublishStep[] } | null>(null);
if (!status.available) {
return (
<div className="notice error">
git isn&apos;t usable on the portfolio checkout: <code>{status.error}</code>
</div>
);
}
const ahead = status.ahead ?? 0;
const nothingToDo = status.changes.length === 0 && ahead === 0;
async function run() {
setRunning(true);
setResult(null);
try {
const res = await api<{ ok: boolean; steps: PublishStep[]; status: GitStatus }>("/api/publish", {
method: "POST",
json: { message },
});
setResult(res);
setStatus(res.status);
} catch (err) {
setResult({ ok: false, steps: [{ step: "stage", status: "failed", output: (err as Error).message }] });
} finally {
setRunning(false);
router.refresh();
}
}
return (
<>
<p className="muted">
Branch <code>{status.branch}</code>
{status.ahead !== undefined && (
<>
{" "}· {status.ahead} ahead / {status.behind} behind upstream (as of last fetch)
</>
)}
. Only <code>data/blog-posts.json</code> and <code>public/img/blog/</code> are committed.
</p>
<h2>Pending changes</h2>
{status.changes.length === 0 ? (
<p className="muted">No uncommitted blog changes.{ahead > 0 && ` ${ahead} commit(s) waiting to be pushed.`}</p>
) : (
<table className="list" style={{ marginBottom: 12 }}>
<tbody>
{status.changes.map((c) => (
<tr key={c.path}>
<td className="mono small" style={{ width: 40 }}>
{c.code}
</td>
<td className="mono small">{c.path}</td>
</tr>
))}
</tbody>
</table>
)}
{status.diffStat && <pre className="log">{status.diffStat}</pre>}
<h2 style={{ marginTop: 24 }}>Publish</h2>
<p className="muted small">
commit → {config.push ? `push (${config.pushAuth})` : "push disabled (GIT_PUSH=false)"} →{" "}
{config.deploy ? `trigger redeploy at ${config.deployUrl}` : "redeploy trigger not configured"}
</p>
<div className="row" style={{ marginBottom: 16 }}>
<input
style={{ flex: 1, minWidth: 260 }}
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="commit message"
/>
<button className="primary" disabled={running || nothingToDo || !message.trim()} onClick={run}>
{running ? "Publishing…" : "Publish"}
</button>
</div>
{result && (
<>
<div className={`notice ${result.ok ? "ok" : "error"}`}>{result.ok ? "Published." : "Publish failed. See log."}</div>
<pre className="log">
{result.steps.map((s) => `[${s.status}] ${s.step}${s.output ? `\n${s.output}` : ""}`).join("\n\n")}
</pre>
</>
)}
</>
);
}

57
components/TagInput.tsx Normal file
View File

@@ -0,0 +1,57 @@
"use client";
import { useId, useState } from "react";
import { normalizeTags } from "@/lib/validate";
type Props = { value: string[]; onChange: (tags: string[]) => void; suggestions: string[] };
/** Chip-style tag entry: Enter or comma adds, Backspace on empty removes the last. */
export default function TagInput({ value, onChange, suggestions }: Props) {
const [draft, setDraft] = useState("");
const listId = useId();
function commit(text: string) {
if (!text.trim()) return;
onChange(normalizeTags([...value, ...text.split(",")]));
setDraft("");
}
return (
<div className="tag-input">
{value.map((tag) => (
<span key={tag} className="chip">
{tag}
<button type="button" aria-label={`remove ${tag}`} onClick={() => onChange(value.filter((t) => t !== tag))}>
×
</button>
</span>
))}
<input
list={listId}
value={draft}
placeholder={value.length ? "" : "add tags…"}
onChange={(e) => {
const v = e.target.value;
if (v.endsWith(",")) commit(v);
else setDraft(v);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit(draft);
} else if (e.key === "Backspace" && !draft && value.length) {
onChange(value.slice(0, -1));
}
}}
onBlur={() => commit(draft)}
/>
<datalist id={listId}>
{suggestions
.filter((s) => !value.includes(s))
.map((s) => (
<option key={s} value={s} />
))}
</datalist>
</div>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/client";
import { categoryLabel, slugify } from "@/lib/validate";
type Item = { name: string; count: number };
type Props = { kind: "category" | "tag"; items: Item[]; revision: string };
/** Rename / merge (both kinds) and remove (tags only) across every post. */
export default function TaxonomyTable({ kind, items, revision }: Props) {
const router = useRouter();
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [busy, setBusy] = useState<string | null>(null);
const [message, setMessage] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
const endpoint = kind === "category" ? "/api/categories" : "/api/tags";
const names = new Set(items.map((i) => i.name));
async function apply(from: string, to: string) {
const target = kind === "category" ? slugify(to) : to.trim();
if (kind === "category" && !target) return;
if (target === from) return;
if (!target && !window.confirm(`Remove the tag "${from}" from every post?`)) return;
if (target && names.has(target) && !window.confirm(`"${target}" already exists. Merge "${from}" into it?`)) return;
setBusy(from);
setMessage(null);
try {
const res = await api<{ changed: number }>(endpoint, { method: "PATCH", json: { from, to: target, revision } });
const verb = target ? `renamed to "${target}"` : "removed";
setMessage({ kind: "ok", text: `"${from}" ${verb} on ${res.changed} post(s).` });
setDrafts((d) => ({ ...d, [from]: "" }));
router.refresh();
} catch (err) {
setMessage({ kind: "error", text: (err as Error).message });
} finally {
setBusy(null);
}
}
return (
<>
{message && <div className={`notice ${message.kind}`}>{message.text}</div>}
<table className="list">
<thead>
<tr>
<th>{kind}</th>
{kind === "category" && <th>displayed as</th>}
<th>posts</th>
<th>rename / merge into</th>
<th />
</tr>
</thead>
<tbody>
{items.map((item) => {
const draft = drafts[item.name] ?? "";
return (
<tr key={item.name}>
<td>
<span className="chip">{item.name}</span>
</td>
{kind === "category" && <td>{categoryLabel(item.name)}</td>}
<td className="mono small">{item.count}</td>
<td>
<input
value={draft}
placeholder={kind === "category" ? "new-category-slug" : "new tag"}
onChange={(e) => setDrafts((d) => ({ ...d, [item.name]: e.target.value }))}
onKeyDown={(e) => e.key === "Enter" && draft.trim() && apply(item.name, draft)}
/>
</td>
<td className="actions">
<button disabled={!draft.trim() || busy === item.name} onClick={() => apply(item.name, draft)}>
{names.has(kind === "category" ? slugify(draft) : draft.trim()) ? "Merge" : "Rename"}
</button>
{kind === "tag" && (
<button className="danger" disabled={busy === item.name} onClick={() => apply(item.name, "")}>
Remove
</button>
)}
</td>
</tr>
);
})}
{items.length === 0 && (
<tr>
<td colSpan={5} className="muted">
None yet.
</td>
</tr>
)}
</tbody>
</table>
</>
);
}