From 96ea99b9535f00ae261e194e94ef6f60e75d48a7 Mon Sep 17 00:00:00 2001 From: kawa Date: Sun, 7 Jun 2026 15:18:47 +0200 Subject: [PATCH] feat: rich post editor + downloads section Add a Markdown editor for post bodies with a formatting toolbar (bold/italic/headings/lists/code/link/image) and a live Write/Preview toggle. The body still submits as a plain field so saving works without JS. Add a per-post downloads section: upload files to the blog or attach external links. Uploads POST to /admin/upload (admin-guarded, sanitized names, 200MB cap), are stored under data/uploads, and served back via the /uploads/[name] route. Downloads render on the post page with file vs link icons and sizes, and are carried through export/import. Co-Authored-By: Claude Opus 4.8 --- .../admin/(panel)/posts/DownloadEditor.tsx | 161 +++++++++++++++ .../admin/(panel)/posts/MarkdownEditor.tsx | 170 +++++++++++++++ src/app/admin/(panel)/posts/PostForm.tsx | 23 ++- src/app/admin/actions.ts | 6 + src/app/admin/admin.css | 194 ++++++++++++++++++ src/app/admin/export/route.ts | 1 + src/app/admin/upload/route.ts | 45 ++++ src/app/globals.css | 48 +++++ src/app/posts/[slug]/page.tsx | 27 +++ src/app/uploads/[name]/route.ts | 32 +++ src/lib/db.ts | 10 +- src/lib/posts.ts | 71 ++++++- src/lib/uploads.ts | 64 ++++++ 13 files changed, 838 insertions(+), 14 deletions(-) create mode 100644 src/app/admin/(panel)/posts/DownloadEditor.tsx create mode 100644 src/app/admin/(panel)/posts/MarkdownEditor.tsx create mode 100644 src/app/admin/upload/route.ts create mode 100644 src/app/uploads/[name]/route.ts create mode 100644 src/lib/uploads.ts diff --git a/src/app/admin/(panel)/posts/DownloadEditor.tsx b/src/app/admin/(panel)/posts/DownloadEditor.tsx new file mode 100644 index 0000000..8e634d6 --- /dev/null +++ b/src/app/admin/(panel)/posts/DownloadEditor.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useRef, useState } from "react"; +import type { Download } from "@/lib/posts"; + +// Manages a post's download section: a mix of uploaded files and external links. +// Uploads POST to /admin/upload and come back as a /uploads/ URL; external +// links are entered by hand. The whole list is serialized to a hidden input +// (name="downloads") as JSON, mirroring the console/game pickers' pattern. + +function formatSize(bytes?: number): string { + if (!bytes && bytes !== 0) return ""; + const units = ["B", "KB", "MB", "GB"]; + let n = bytes; + let u = 0; + while (n >= 1024 && u < units.length - 1) { + n /= 1024; + u++; + } + return `${n >= 10 || u === 0 ? Math.round(n) : n.toFixed(1)} ${units[u]}`; +} + +export default function DownloadEditor({ + name, + initial, +}: { + name: string; + initial: Download[]; +}) { + const [items, setItems] = useState(initial); + const [linkLabel, setLinkLabel] = useState(""); + const [linkUrl, setLinkUrl] = useState(""); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const fileRef = useRef(null); + + function setLabel(idx: number, label: string) { + setItems((prev) => prev.map((it, i) => (i === idx ? { ...it, label } : it))); + } + + function remove(idx: number) { + setItems((prev) => prev.filter((_, i) => i !== idx)); + } + + function addLink() { + const url = linkUrl.trim(); + if (!url) return; + setItems((prev) => [ + ...prev, + { label: linkLabel.trim() || url, url, kind: "link" }, + ]); + setLinkLabel(""); + setLinkUrl(""); + } + + async function onFiles(files: FileList | null) { + if (!files || files.length === 0) return; + setError(null); + setUploading(true); + try { + for (const file of Array.from(files)) { + const fd = new FormData(); + fd.append("file", file); + const res = await fetch("/admin/upload", { method: "POST", body: fd }); + if (!res.ok) { + const j = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(j.error || `upload failed (${res.status})`); + } + const j = (await res.json()) as { url: string; filename: string; size: number }; + setItems((prev) => [ + ...prev, + { label: j.filename, url: j.url, kind: "file", size: j.size, filename: j.filename }, + ]); + } + } catch (e) { + setError(e instanceof Error ? e.message : "upload failed"); + } finally { + setUploading(false); + if (fileRef.current) fileRef.current.value = ""; + } + } + + return ( +
+ + + {items.length > 0 && ( + + )} + +
+
+ setLinkLabel(e.target.value)} + /> + setLinkUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addLink(); + } + }} + /> + +
+ +
+ onFiles(e.target.files)} + /> + {uploading && Uploading…} +
+ + {error &&

{error}

} +
+
+ ); +} diff --git a/src/app/admin/(panel)/posts/MarkdownEditor.tsx b/src/app/admin/(panel)/posts/MarkdownEditor.tsx new file mode 100644 index 0000000..b3ecd73 --- /dev/null +++ b/src/app/admin/(panel)/posts/MarkdownEditor.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useRef, useState } from "react"; +import { marked } from "marked"; + +// Richer Markdown editor for the post body. A formatting toolbar wraps/inserts +// syntax around the textarea selection, and a Write/Preview toggle renders the +// same `marked` output the server uses. The textarea keeps name="body" so the +// surrounding server-action form submits it exactly as before β€” no JS required +// to save, the toolbar is pure enhancement. + +type Wrap = { before: string; after: string; placeholder: string }; + +type ToolButton = + | { kind: "wrap"; label: string; title: string; wrap: Wrap } + | { kind: "prefix"; label: string; title: string; prefix: string } + | { kind: "link" } + | { kind: "image" }; + +const TOOLS: ToolButton[] = [ + { kind: "wrap", label: "B", title: "Bold", wrap: { before: "**", after: "**", placeholder: "bold text" } }, + { kind: "wrap", label: "I", title: "Italic", wrap: { before: "_", after: "_", placeholder: "italic text" } }, + { kind: "wrap", label: "S", title: "Strikethrough", wrap: { before: "~~", after: "~~", placeholder: "struck" } }, + { kind: "wrap", label: "", title: "Inline code", wrap: { before: "`", after: "`", placeholder: "code" } }, + { kind: "prefix", label: "H1", title: "Heading 1", prefix: "# " }, + { kind: "prefix", label: "H2", title: "Heading 2", prefix: "## " }, + { kind: "prefix", label: "H3", title: "Heading 3", prefix: "### " }, + { kind: "prefix", label: "β€œ ”", title: "Quote", prefix: "> " }, + { kind: "prefix", label: "β€’ List", title: "Bulleted list", prefix: "- " }, + { kind: "prefix", label: "1. List", title: "Numbered list", prefix: "1. " }, + { kind: "wrap", label: "{ }", title: "Code block", wrap: { before: "```\n", after: "\n```", placeholder: "code block" } }, + { kind: "link" }, + { kind: "image" }, +]; + +export default function MarkdownEditor({ + name, + defaultValue = "", + rows = 18, +}: { + name: string; + defaultValue?: string; + rows?: number; +}) { + const ref = useRef(null); + const [value, setValue] = useState(defaultValue); + const [tab, setTab] = useState<"write" | "preview">("write"); + + // Replace the current selection and restore focus + a sensible caret/selection + // so chained formatting feels native. + function apply(transform: (sel: string) => { text: string; selStart: number; selEnd: number }) { + const el = ref.current; + if (!el) return; + const start = el.selectionStart; + const end = el.selectionEnd; + const selected = value.slice(start, end); + const { text, selStart, selEnd } = transform(selected); + const next = value.slice(0, start) + text + value.slice(end); + setValue(next); + requestAnimationFrame(() => { + el.focus(); + el.setSelectionRange(start + selStart, start + selEnd); + }); + } + + function wrap(w: Wrap) { + apply((sel) => { + const inner = sel || w.placeholder; + return { + text: w.before + inner + w.after, + selStart: w.before.length, + selEnd: w.before.length + inner.length, + }; + }); + } + + function prefix(p: string) { + apply((sel) => { + const lines = (sel || "").split("\n"); + const text = lines.map((l) => p + l).join("\n"); + return { text, selStart: 0, selEnd: text.length }; + }); + } + + function link() { + const url = window.prompt("Link URL", "https://"); + if (!url) return; + apply((sel) => { + const label = sel || "link text"; + const text = `[${label}](${url})`; + return { text, selStart: 1, selEnd: 1 + label.length }; + }); + } + + function image() { + const url = window.prompt("Image URL", "https://"); + if (!url) return; + apply((sel) => { + const alt = sel || "alt text"; + const text = `![${alt}](${url})`; + return { text, selStart: 2, selEnd: 2 + alt.length }; + }); + } + + function onTool(t: ToolButton) { + if (t.kind === "wrap") wrap(t.wrap); + else if (t.kind === "prefix") prefix(t.prefix); + else if (t.kind === "link") link(); + else image(); + } + + const previewHtml = marked.parse(value || "*Nothing to preview yet.*", { async: false }) as string; + + return ( +
+
+
+ {TOOLS.map((t, i) => ( + + ))} +
+
+ + +
+
+ + {tab === "write" ? ( +