initial commit
This commit is contained in:
216
components/ImagePanel.tsx
Normal file
216
components/ImagePanel.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { api } from "@/lib/client";
|
||||
import { imageDescription, imageSrc, type GalleryImage, type ImageFile } from "@/lib/types";
|
||||
|
||||
/* Two related things in one panel:
|
||||
* - files: what's in public/img/blog/<slug>/ (upload / delete / insert)
|
||||
* - gallery: the post's images[] (ordered; the first is the card cover).
|
||||
* Uploading and deleting files happens immediately; gallery edits are part
|
||||
* of the post and are saved with it. */
|
||||
|
||||
type Props = {
|
||||
/** The saved slug; uploads are disabled until the post exists. */
|
||||
slug: string | null;
|
||||
files: ImageFile[];
|
||||
onFilesChange: (files: ImageFile[]) => void;
|
||||
gallery: GalleryImage[];
|
||||
onGalleryChange: (gallery: GalleryImage[]) => void;
|
||||
onInsert: (file: ImageFile) => void;
|
||||
};
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(bytes / 1024)} KB`;
|
||||
}
|
||||
|
||||
function withDescription(src: string, description: string): GalleryImage {
|
||||
return description.trim() ? { src, description } : src;
|
||||
}
|
||||
|
||||
export default function ImagePanel({ slug, files, onFilesChange, gallery, onGalleryChange, onInsert }: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [over, setOver] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const galleryIndex = (src: string) => gallery.findIndex((g) => imageSrc(g) === src);
|
||||
|
||||
async function upload(list: FileList | File[]) {
|
||||
if (!slug || !list.length) return;
|
||||
const form = new FormData();
|
||||
for (const f of Array.from(list)) form.append("file", f);
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api<{ files: ImageFile[] }>(`/api/images/${slug}`, { method: "POST", body: form });
|
||||
onFilesChange(res.files);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(file: ImageFile) {
|
||||
if (!slug || !window.confirm(`Delete ${file.name} from disk?`)) return;
|
||||
try {
|
||||
const res = await api<{ files: ImageFile[] }>(
|
||||
`/api/images/${slug}?name=${encodeURIComponent(file.name)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
onFilesChange(res.files);
|
||||
if (galleryIndex(file.src) >= 0) onGalleryChange(gallery.filter((g) => imageSrc(g) !== file.src));
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function move(index: number, delta: number) {
|
||||
const next = [...gallery];
|
||||
const [item] = next.splice(index, 1);
|
||||
next.splice(index + delta, 0, item);
|
||||
onGalleryChange(next);
|
||||
}
|
||||
|
||||
// Gallery entries pointing outside this post's folder (e.g. hand-added
|
||||
// paths) still need to be manageable, so list them too.
|
||||
const fileSrcs = new Set(files.map((f) => f.src));
|
||||
const external = gallery.filter((g) => !fileSrcs.has(imageSrc(g)));
|
||||
|
||||
return (
|
||||
<section className="images">
|
||||
<h2>Images</h2>
|
||||
{error && <div className="notice error">{error}</div>}
|
||||
|
||||
{gallery.length > 0 && (
|
||||
<>
|
||||
<p className="muted small">
|
||||
Gallery (shown below the post; the first image is also the card cover). Edits here are saved with the
|
||||
post.
|
||||
</p>
|
||||
<table className="list" style={{ marginBottom: 20 }}>
|
||||
<tbody>
|
||||
{gallery.map((img, i) => (
|
||||
<tr key={imageSrc(img)}>
|
||||
<td className="mono small" style={{ width: 30 }}>
|
||||
{i === 0 ? "cover" : i + 1}
|
||||
</td>
|
||||
<td style={{ width: 80 }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={"/" + imageSrc(img)} alt="" style={{ height: 40, borderRadius: 4 }} />
|
||||
</td>
|
||||
<td className="mono small">{imageSrc(img)}</td>
|
||||
<td>
|
||||
<input
|
||||
placeholder="description (alt text)"
|
||||
value={imageDescription(img) ?? ""}
|
||||
onChange={(e) =>
|
||||
onGalleryChange(gallery.map((g, j) => (j === i ? withDescription(imageSrc(g), e.target.value) : g)))
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="actions">
|
||||
<button type="button" className="ghost" disabled={i === 0} onClick={() => move(i, -1)} title="Move up">
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
disabled={i === gallery.length - 1}
|
||||
onClick={() => move(i, 1)}
|
||||
title="Move down"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button type="button" onClick={() => onGalleryChange(gallery.filter((_, j) => j !== i))}>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{external.length > 0 && (
|
||||
<p className="muted small">
|
||||
{external.length} gallery image(s) live outside public/img/blog/{slug ?? "<slug>"}/.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!slug ? (
|
||||
<div className="image-drop">Save the post once to enable image uploads.</div>
|
||||
) : (
|
||||
<div
|
||||
className={`image-drop${over ? " over" : ""}`}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setOver(true);
|
||||
}}
|
||||
onDragLeave={() => setOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setOver(false);
|
||||
upload(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
{busy ? "Uploading…" : `Drop images here or click to upload → public/img/blog/${slug}/`}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".png,.jpg,.jpeg,.gif,.webp,.svg,.mp4"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
if (e.target.files) upload(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="image-grid">
|
||||
{files.map((file) => {
|
||||
const inGallery = galleryIndex(file.src) >= 0;
|
||||
return (
|
||||
<div key={file.name} className={`image-card${inGallery ? " in-gallery" : ""}`}>
|
||||
<div className="thumb">
|
||||
{/\.mp4$/i.test(file.name) ? (
|
||||
<video src={"/" + file.src} muted />
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={"/" + file.src} alt={file.name} />
|
||||
)}
|
||||
</div>
|
||||
<div className="body">
|
||||
<div className="name">
|
||||
{file.name} <span className="muted">· {formatSize(file.size)}</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button type="button" onClick={() => onInsert(file)} title="Insert at cursor in the body">
|
||||
Insert
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onGalleryChange(
|
||||
inGallery ? gallery.filter((g) => imageSrc(g) !== file.src) : [...gallery, file.src]
|
||||
)
|
||||
}
|
||||
>
|
||||
{inGallery ? "− Gallery" : "+ Gallery"}
|
||||
</button>
|
||||
<button type="button" className="danger" onClick={() => remove(file)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
174
components/MarkdownEditor.tsx
Normal file
174
components/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import CodeMirror, { EditorSelection, EditorView, keymap } from "@uiw/react-codemirror";
|
||||
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
|
||||
import { languages } from "@codemirror/language-data";
|
||||
import { useSystemDark } from "@/lib/useSystemDark";
|
||||
|
||||
/* Markdown source editor with a formatting toolbar. Source (not WYSIWYG) on
|
||||
* purpose: posts embed raw HTML blocks that rich-text editors would mangle.
|
||||
* The live preview beside it is what makes it visual. */
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Receives the EditorView so the parent can insert text (e.g. images). */
|
||||
onView?: (view: EditorView | null) => void;
|
||||
};
|
||||
|
||||
/** Wrap each selection in `before`/`after`, or unwrap if already wrapped. */
|
||||
export function wrap(view: EditorView, before: string, after = before, placeholder = "text") {
|
||||
view.dispatch(
|
||||
view.state.changeByRange((range) => {
|
||||
const doc = view.state.doc;
|
||||
const outerFrom = range.from - before.length;
|
||||
const outerTo = range.to + after.length;
|
||||
const isWrapped =
|
||||
outerFrom >= 0 &&
|
||||
outerTo <= doc.length &&
|
||||
doc.sliceString(outerFrom, range.from) === before &&
|
||||
doc.sliceString(range.to, outerTo) === after;
|
||||
if (isWrapped) {
|
||||
return {
|
||||
changes: [
|
||||
{ from: outerFrom, to: range.from, insert: "" },
|
||||
{ from: range.to, to: outerTo, insert: "" },
|
||||
],
|
||||
range: EditorSelection.range(outerFrom, range.to - before.length),
|
||||
};
|
||||
}
|
||||
const text = range.empty ? placeholder : doc.sliceString(range.from, range.to);
|
||||
return {
|
||||
changes: { from: range.from, to: range.to, insert: before + text + after },
|
||||
range: EditorSelection.range(range.from + before.length, range.from + before.length + text.length),
|
||||
};
|
||||
})
|
||||
);
|
||||
view.focus();
|
||||
}
|
||||
|
||||
/** Toggle a prefix on every line touched by the selection ("# ", "> ", "- "...). */
|
||||
export function prefixLines(view: EditorView, prefix: string | ((i: number) => string)) {
|
||||
const { state } = view;
|
||||
const changes: { from: number; to?: number; insert: string }[] = [];
|
||||
const seen = new Set<number>();
|
||||
for (const range of state.selection.ranges) {
|
||||
const first = state.doc.lineAt(range.from).number;
|
||||
const last = state.doc.lineAt(range.to).number;
|
||||
for (let n = first, i = 0; n <= last; n++, i++) {
|
||||
if (seen.has(n)) continue;
|
||||
seen.add(n);
|
||||
const line = state.doc.line(n);
|
||||
const p = typeof prefix === "function" ? prefix(i) : prefix;
|
||||
// Strip any existing heading/quote/list marker so prefixes replace each other.
|
||||
const existing = /^(#{1,6} |> |- |\d+\. )/.exec(line.text)?.[0] ?? "";
|
||||
if (existing === p) changes.push({ from: line.from, to: line.from + existing.length, insert: "" });
|
||||
else changes.push({ from: line.from, to: line.from + existing.length, insert: p });
|
||||
}
|
||||
}
|
||||
view.dispatch({ changes });
|
||||
view.focus();
|
||||
}
|
||||
|
||||
/** Replace the selection with `text` (on its own lines when `block`). */
|
||||
export function insertText(view: EditorView, text: string, block = false) {
|
||||
const { state } = view;
|
||||
const range = state.selection.main;
|
||||
let insert = text;
|
||||
if (block) {
|
||||
const before = state.doc.sliceString(Math.max(0, range.from - 2), range.from);
|
||||
const after = state.doc.sliceString(range.to, range.to + 1);
|
||||
if (range.from > 0 && !before.endsWith("\n\n")) insert = (before.endsWith("\n") ? "\n" : "\n\n") + insert;
|
||||
if (after !== "\n") insert += "\n";
|
||||
}
|
||||
view.dispatch({
|
||||
changes: { from: range.from, to: range.to, insert },
|
||||
selection: EditorSelection.cursor(range.from + insert.length),
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function link(view: EditorView) {
|
||||
const url = window.prompt("Link URL", "https://");
|
||||
if (!url) return;
|
||||
wrap(view, "[", `](${url})`, "link text");
|
||||
}
|
||||
|
||||
const BLOCKQUOTE_HTML = "<blockquote>\nQuote text\n</blockquote>";
|
||||
|
||||
export default function MarkdownEditor({ value, onChange, onView }: Props) {
|
||||
const [view, setView] = useState<EditorView | null>(null);
|
||||
const dark = useSystemDark();
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
markdown({ base: markdownLanguage, codeLanguages: languages }),
|
||||
EditorView.lineWrapping,
|
||||
keymap.of([
|
||||
{ key: "Mod-b", run: (v) => (wrap(v, "**"), true) },
|
||||
{ key: "Mod-i", run: (v) => (wrap(v, "*"), true) },
|
||||
{ key: "Mod-k", run: (v) => (link(v), true) },
|
||||
]),
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const tools: ({ label: string; title: string; run: (v: EditorView) => void } | "sep")[] = [
|
||||
{ label: "H1", title: "Heading 1", run: (v) => prefixLines(v, "# ") },
|
||||
{ label: "H2", title: "Heading 2", run: (v) => prefixLines(v, "## ") },
|
||||
{ label: "H3", title: "Heading 3", run: (v) => prefixLines(v, "### ") },
|
||||
"sep",
|
||||
{ label: "B", title: "Bold (Ctrl+B)", run: (v) => wrap(v, "**") },
|
||||
{ label: "I", title: "Italic (Ctrl+I)", run: (v) => wrap(v, "*") },
|
||||
{ label: "</>", title: "Inline code", run: (v) => wrap(v, "`", "`", "code") },
|
||||
{ label: "link", title: "Link (Ctrl+K)", run: link },
|
||||
"sep",
|
||||
{ label: "• list", title: "Bullet list", run: (v) => prefixLines(v, "- ") },
|
||||
{ label: "1. list", title: "Numbered list", run: (v) => prefixLines(v, (i) => `${i + 1}. `) },
|
||||
{ label: "> quote", title: "Markdown quote", run: (v) => prefixLines(v, "> ") },
|
||||
{ label: "<quote>", title: "HTML blockquote (the site's raised style)", run: (v) => insertText(v, BLOCKQUOTE_HTML, true) },
|
||||
{ label: "```", title: "Code block", run: (v) => wrap(v, "```\n", "\n```", "code") },
|
||||
{ label: "—", title: "Horizontal rule", run: (v) => insertText(v, "---", true) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="pane">
|
||||
<div className="pane-head">
|
||||
{tools.map((tool, i) =>
|
||||
tool === "sep" ? (
|
||||
<span key={i} className="tb-sep" />
|
||||
) : (
|
||||
<button
|
||||
key={tool.label}
|
||||
type="button"
|
||||
title={tool.title}
|
||||
disabled={!view}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => view && tool.run(view)}
|
||||
>
|
||||
{tool.label}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<span className="spacer" />
|
||||
<span>markdown</span>
|
||||
</div>
|
||||
<div className="pane-body">
|
||||
<CodeMirror
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
extensions={extensions}
|
||||
theme={dark ? "dark" : "light"}
|
||||
height="100%"
|
||||
style={{ position: "absolute", inset: 0 }}
|
||||
basicSetup={{ foldGutter: false, highlightActiveLine: false }}
|
||||
onCreateEditor={(v) => {
|
||||
setView(v);
|
||||
onView?.(v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
127
components/PostTable.tsx
Normal file
127
components/PostTable.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/client";
|
||||
import { categoryLabel } from "@/lib/validate";
|
||||
import type { BlogPost } from "@/lib/types";
|
||||
|
||||
type Props = { posts: BlogPost[]; revision: string; categories: string[] };
|
||||
|
||||
export default function PostTable({ posts, revision, categories }: Props) {
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return posts.filter(
|
||||
(p) =>
|
||||
(!category || p.category === category) &&
|
||||
(!q || [p.title, p.slug, p.description, ...p.tags].some((s) => s.toLowerCase().includes(q)))
|
||||
);
|
||||
}, [posts, query, category]);
|
||||
|
||||
async function remove(post: BlogPost) {
|
||||
const typed = window.prompt(`Delete "${post.title}"?\n\nType the slug (${post.slug}) to confirm.`);
|
||||
if (typed === null) return;
|
||||
if (typed.trim() !== post.slug) {
|
||||
setError("Slug didn't match; nothing was deleted.");
|
||||
return;
|
||||
}
|
||||
const withImages = window.confirm(
|
||||
`Also delete the image folder public/img/blog/${post.slug}/?\n\nOK = delete images too, Cancel = keep them.`
|
||||
);
|
||||
setBusy(post.slug);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ revision, ...(withImages ? { images: "1" } : {}) });
|
||||
await api(`/api/posts/${post.slug}?${params}`, { method: "DELETE" });
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar-row">
|
||||
<h1 style={{ margin: 0, flex: 1 }}>Posts</h1>
|
||||
<input placeholder="search title, slug, tags…" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
<select value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
<option value="">all categories</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{categoryLabel(c)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Link href="/posts/new" className="button primary">
|
||||
+ New post
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="notice error">{error}</div>}
|
||||
|
||||
<table className="list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>title</th>
|
||||
<th>date</th>
|
||||
<th>category</th>
|
||||
<th>tags</th>
|
||||
<th>images</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visible.map((post) => (
|
||||
<tr key={post.slug}>
|
||||
<td>
|
||||
<Link href={`/posts/${post.slug}`} className="title-cell">
|
||||
{post.title}
|
||||
</Link>
|
||||
<div className="mono small muted">/blog/{post.slug}</div>
|
||||
</td>
|
||||
<td className="mono small">{post.date}</td>
|
||||
<td>
|
||||
<span className="chip">{post.category}</span>
|
||||
</td>
|
||||
<td>
|
||||
{post.tags.map((t) => (
|
||||
<span key={t} className="chip">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
<td className="mono small">{post.images?.length ?? 0}</td>
|
||||
<td className="actions">
|
||||
<Link href={`/posts/${post.slug}`} className="button">
|
||||
Edit
|
||||
</Link>
|
||||
<button className="danger" disabled={busy === post.slug} onClick={() => remove(post)}>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{visible.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="muted">
|
||||
{posts.length ? "No posts match." : "No posts yet."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="muted small">
|
||||
{visible.length} of {posts.length} posts
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
131
components/PreviewFrame.tsx
Normal file
131
components/PreviewFrame.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/client";
|
||||
import { categoryLabel } from "@/lib/validate";
|
||||
import { useSystemDark } from "@/lib/useSystemDark";
|
||||
import { imageDescription, imageSrc, type GalleryImage } from "@/lib/types";
|
||||
|
||||
/* Live preview of a post page. The HTML comes from the site's own remark
|
||||
* pipeline (/api/preview) and is painted inside an iframe that loads the
|
||||
* site's real stylesheets (served from the checkout via /site/...), so the
|
||||
* site's CSS can't leak into the manager and the preview matches the live
|
||||
* page. The iframe shell is created once; updates swap its content in place
|
||||
* so scroll position survives typing. */
|
||||
|
||||
const SHELL = `<!doctype html>
|
||||
<html data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<base href="/site/">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=JetBrains+Mono:wght@400;500;700&display=swap">
|
||||
<style>:root { --mono: "JetBrains Mono", monospace; --sans: "DM Sans", sans-serif; }</style>
|
||||
<link rel="stylesheet" href="/site/app/globals.css">
|
||||
<link rel="stylesheet" href="/site/styles/markdown-container.css">
|
||||
<link rel="stylesheet" href="/site/styles/blog.css">
|
||||
<style>
|
||||
body { max-width: 880px; margin: 0 auto; padding: 8px 24px 48px; }
|
||||
.bm-gallery { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 2em; }
|
||||
.bm-gallery img, .bm-gallery video { height: 120px; border-radius: 4px; border: 1px solid var(--border); }
|
||||
.bm-empty { color: var(--muted); font-family: var(--mono); font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="blog-markdown-container" class="markdown-container"></div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
type Props = {
|
||||
markdown: string;
|
||||
category: string;
|
||||
images: GalleryImage[];
|
||||
title: string;
|
||||
};
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
||||
}
|
||||
|
||||
function galleryHtml(images: GalleryImage[], title: string): string {
|
||||
if (!images.length) return "";
|
||||
const items = images
|
||||
.map((img) => {
|
||||
const src = "/" + imageSrc(img);
|
||||
const alt = escapeHtml(imageDescription(img) ?? title);
|
||||
return /\.mp4$/i.test(src)
|
||||
? `<video src="${escapeHtml(src)}" muted></video>`
|
||||
: `<img src="${escapeHtml(src)}" alt="${alt}" title="${alt}">`;
|
||||
})
|
||||
.join("");
|
||||
return `<section class="project-details-gallery"><h2>Gallery</h2><div class="bm-gallery">${items}</div></section>`;
|
||||
}
|
||||
|
||||
export default function PreviewFrame({ markdown, category, images, title }: Props) {
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [frameReady, setFrameReady] = useState(false);
|
||||
// Follows the OS theme until toggled by hand.
|
||||
const systemDark = useSystemDark();
|
||||
const [themeOverride, setTheme] = useState<"light" | "dark" | null>(null);
|
||||
const theme = themeOverride ?? (systemDark ? "dark" : "light");
|
||||
|
||||
// Debounced render through the site's pipeline.
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await api<{ html: string }>("/api/preview", {
|
||||
method: "POST",
|
||||
json: { markdown },
|
||||
signal: controller.signal,
|
||||
});
|
||||
setHtml(res.html);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted) setError((err as Error).message);
|
||||
}
|
||||
}, 250);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [markdown]);
|
||||
|
||||
useEffect(() => {
|
||||
const doc = frameRef.current?.contentDocument;
|
||||
if (!frameReady || !doc) return;
|
||||
doc.documentElement.dataset.theme = theme;
|
||||
const container = doc.getElementById("blog-markdown-container");
|
||||
if (!container || html === null) return;
|
||||
const chip = category
|
||||
? `<a class="blog-category-chip is-active blog-post-category-chip">${escapeHtml(categoryLabel(category))}</a>`
|
||||
: "";
|
||||
const body = html.trim() ? `<main>${html}</main>` : `<p class="bm-empty">Nothing to preview yet.</p>`;
|
||||
// Same structure as portfolio/app/blog/[slug]/page.tsx.
|
||||
container.innerHTML = chip + body + galleryHtml(images, title);
|
||||
}, [frameReady, html, category, images, title, theme]);
|
||||
|
||||
return (
|
||||
<div className="pane">
|
||||
<div className="pane-head">
|
||||
<span>preview</span>
|
||||
{error && <span className="badge err">{error}</span>}
|
||||
<span className="spacer" />
|
||||
<button type="button" onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
|
||||
{theme === "light" ? "☾ dark" : "☀ light"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="pane-body">
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
className="preview-frame"
|
||||
title="Post preview"
|
||||
srcDoc={SHELL}
|
||||
onLoad={() => setFrameReady(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
components/PublishPanel.tsx
Normal file
110
components/PublishPanel.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/client";
|
||||
import type { GitStatus } from "@/lib/git";
|
||||
import type { PublishStep } from "@/lib/publish";
|
||||
|
||||
type Config = { push: boolean; pushAuth: string; deploy: boolean; deployUrl: string | null };
|
||||
|
||||
function defaultMessage(status: GitStatus): string {
|
||||
const n = status.changes.length;
|
||||
return n === 1 && status.changes[0].path.endsWith("blog-posts.json") ? "blog: update posts" : `blog: update posts (${n} files)`;
|
||||
}
|
||||
|
||||
export default function PublishPanel({ status: initial, config }: { status: GitStatus; config: Config }) {
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState(initial);
|
||||
const [message, setMessage] = useState(() => defaultMessage(initial));
|
||||
const [running, setRunning] = useState(false);
|
||||
const [result, setResult] = useState<{ ok: boolean; steps: PublishStep[] } | null>(null);
|
||||
|
||||
if (!status.available) {
|
||||
return (
|
||||
<div className="notice error">
|
||||
git isn't usable on the portfolio checkout: <code>{status.error}</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ahead = status.ahead ?? 0;
|
||||
const nothingToDo = status.changes.length === 0 && ahead === 0;
|
||||
|
||||
async function run() {
|
||||
setRunning(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await api<{ ok: boolean; steps: PublishStep[]; status: GitStatus }>("/api/publish", {
|
||||
method: "POST",
|
||||
json: { message },
|
||||
});
|
||||
setResult(res);
|
||||
setStatus(res.status);
|
||||
} catch (err) {
|
||||
setResult({ ok: false, steps: [{ step: "stage", status: "failed", output: (err as Error).message }] });
|
||||
} finally {
|
||||
setRunning(false);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="muted">
|
||||
Branch <code>{status.branch}</code>
|
||||
{status.ahead !== undefined && (
|
||||
<>
|
||||
{" "}· {status.ahead} ahead / {status.behind} behind upstream (as of last fetch)
|
||||
</>
|
||||
)}
|
||||
. Only <code>data/blog-posts.json</code> and <code>public/img/blog/</code> are committed.
|
||||
</p>
|
||||
|
||||
<h2>Pending changes</h2>
|
||||
{status.changes.length === 0 ? (
|
||||
<p className="muted">No uncommitted blog changes.{ahead > 0 && ` ${ahead} commit(s) waiting to be pushed.`}</p>
|
||||
) : (
|
||||
<table className="list" style={{ marginBottom: 12 }}>
|
||||
<tbody>
|
||||
{status.changes.map((c) => (
|
||||
<tr key={c.path}>
|
||||
<td className="mono small" style={{ width: 40 }}>
|
||||
{c.code}
|
||||
</td>
|
||||
<td className="mono small">{c.path}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{status.diffStat && <pre className="log">{status.diffStat}</pre>}
|
||||
|
||||
<h2 style={{ marginTop: 24 }}>Publish</h2>
|
||||
<p className="muted small">
|
||||
commit → {config.push ? `push (${config.pushAuth})` : "push disabled (GIT_PUSH=false)"} →{" "}
|
||||
{config.deploy ? `trigger redeploy at ${config.deployUrl}` : "redeploy trigger not configured"}
|
||||
</p>
|
||||
<div className="row" style={{ marginBottom: 16 }}>
|
||||
<input
|
||||
style={{ flex: 1, minWidth: 260 }}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="commit message"
|
||||
/>
|
||||
<button className="primary" disabled={running || nothingToDo || !message.trim()} onClick={run}>
|
||||
{running ? "Publishing…" : "Publish"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<>
|
||||
<div className={`notice ${result.ok ? "ok" : "error"}`}>{result.ok ? "Published." : "Publish failed. See log."}</div>
|
||||
<pre className="log">
|
||||
{result.steps.map((s) => `[${s.status}] ${s.step}${s.output ? `\n${s.output}` : ""}`).join("\n\n")}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
57
components/TagInput.tsx
Normal file
57
components/TagInput.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { normalizeTags } from "@/lib/validate";
|
||||
|
||||
type Props = { value: string[]; onChange: (tags: string[]) => void; suggestions: string[] };
|
||||
|
||||
/** Chip-style tag entry: Enter or comma adds, Backspace on empty removes the last. */
|
||||
export default function TagInput({ value, onChange, suggestions }: Props) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const listId = useId();
|
||||
|
||||
function commit(text: string) {
|
||||
if (!text.trim()) return;
|
||||
onChange(normalizeTags([...value, ...text.split(",")]));
|
||||
setDraft("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tag-input">
|
||||
{value.map((tag) => (
|
||||
<span key={tag} className="chip">
|
||||
{tag}
|
||||
<button type="button" aria-label={`remove ${tag}`} onClick={() => onChange(value.filter((t) => t !== tag))}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
list={listId}
|
||||
value={draft}
|
||||
placeholder={value.length ? "" : "add tags…"}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v.endsWith(",")) commit(v);
|
||||
else setDraft(v);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commit(draft);
|
||||
} else if (e.key === "Backspace" && !draft && value.length) {
|
||||
onChange(value.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
onBlur={() => commit(draft)}
|
||||
/>
|
||||
<datalist id={listId}>
|
||||
{suggestions
|
||||
.filter((s) => !value.includes(s))
|
||||
.map((s) => (
|
||||
<option key={s} value={s} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
components/TaxonomyTable.tsx
Normal file
98
components/TaxonomyTable.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/client";
|
||||
import { categoryLabel, slugify } from "@/lib/validate";
|
||||
|
||||
type Item = { name: string; count: number };
|
||||
type Props = { kind: "category" | "tag"; items: Item[]; revision: string };
|
||||
|
||||
/** Rename / merge (both kinds) and remove (tags only) across every post. */
|
||||
export default function TaxonomyTable({ kind, items, revision }: Props) {
|
||||
const router = useRouter();
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
|
||||
|
||||
const endpoint = kind === "category" ? "/api/categories" : "/api/tags";
|
||||
const names = new Set(items.map((i) => i.name));
|
||||
|
||||
async function apply(from: string, to: string) {
|
||||
const target = kind === "category" ? slugify(to) : to.trim();
|
||||
if (kind === "category" && !target) return;
|
||||
if (target === from) return;
|
||||
if (!target && !window.confirm(`Remove the tag "${from}" from every post?`)) return;
|
||||
if (target && names.has(target) && !window.confirm(`"${target}" already exists. Merge "${from}" into it?`)) return;
|
||||
|
||||
setBusy(from);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await api<{ changed: number }>(endpoint, { method: "PATCH", json: { from, to: target, revision } });
|
||||
const verb = target ? `renamed to "${target}"` : "removed";
|
||||
setMessage({ kind: "ok", text: `"${from}" ${verb} on ${res.changed} post(s).` });
|
||||
setDrafts((d) => ({ ...d, [from]: "" }));
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setMessage({ kind: "error", text: (err as Error).message });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{message && <div className={`notice ${message.kind}`}>{message.text}</div>}
|
||||
<table className="list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{kind}</th>
|
||||
{kind === "category" && <th>displayed as</th>}
|
||||
<th>posts</th>
|
||||
<th>rename / merge into</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => {
|
||||
const draft = drafts[item.name] ?? "";
|
||||
return (
|
||||
<tr key={item.name}>
|
||||
<td>
|
||||
<span className="chip">{item.name}</span>
|
||||
</td>
|
||||
{kind === "category" && <td>{categoryLabel(item.name)}</td>}
|
||||
<td className="mono small">{item.count}</td>
|
||||
<td>
|
||||
<input
|
||||
value={draft}
|
||||
placeholder={kind === "category" ? "new-category-slug" : "new tag"}
|
||||
onChange={(e) => setDrafts((d) => ({ ...d, [item.name]: e.target.value }))}
|
||||
onKeyDown={(e) => e.key === "Enter" && draft.trim() && apply(item.name, draft)}
|
||||
/>
|
||||
</td>
|
||||
<td className="actions">
|
||||
<button disabled={!draft.trim() || busy === item.name} onClick={() => apply(item.name, draft)}>
|
||||
{names.has(kind === "category" ? slugify(draft) : draft.trim()) ? "Merge" : "Rename"}
|
||||
</button>
|
||||
{kind === "tag" && (
|
||||
<button className="danger" disabled={busy === item.name} onClick={() => apply(item.name, "")}>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="muted">
|
||||
None yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user