Files
blog-manager/components/PostEditor.tsx
2026-11-01 13:23:44 -05:00

258 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}