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

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