258 lines
9.2 KiB
TypeScript
258 lines
9.2 KiB
TypeScript
"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 & 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>
|
||
);
|
||
}
|