"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: () =>
, }); 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(null); const [form, setForm] = useState
(() => toForm(post)); const [baseline, setBaseline] = useState(() => JSON.stringify(toForm(post))); const [savedSlug, setSavedSlug] = useState(post?.slug ?? null); const [slugTouched, setSlugTouched] = useState(Boolean(post)); const [revision, setRevision] = useState(initialRevision); const [files, setFiles] = useState(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 = (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) ? `` : `![${file.name.replace(/\.[^.]+$/, "").replace(/[-_]+/g, " ")}](${src})`; insertText(view, snippet, true); } const isNew = !savedSlug; return (

{isNew ? "New post" : form.title || savedSlug}

{dirty && unsaved} Back
{conflict && (
blog-posts.json changed on disk since this page loaded (another edit, or a git pull).
)} {message &&
{message.text}
}