initial commit
This commit is contained in:
257
components/PostEditor.tsx
Normal file
257
components/PostEditor.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user