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 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 15:18:47 +02:00
parent 88ed5d324a
commit 96ea99b953
13 changed files with 838 additions and 14 deletions
@@ -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/<name> 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<Download[]>(initial);
const [linkLabel, setLinkLabel] = useState("");
const [linkUrl, setLinkUrl] = useState("");
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(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 (
<div className="rb-dl">
<input type="hidden" name={name} value={JSON.stringify(items)} />
{items.length > 0 && (
<ul className="rb-dl-list">
{items.map((it, i) => (
<li key={`${it.url}-${i}`} className="rb-dl-row">
<span className="rb-dl-kind" aria-hidden>
{it.kind === "file" ? "📦" : "🔗"}
</span>
<input
className="rb-dl-label"
type="text"
value={it.label}
placeholder="Download label"
onChange={(e) => setLabel(i, e.target.value)}
/>
<a className="rb-dl-url" href={it.url} target="_blank" rel="noreferrer" title={it.url}>
{it.kind === "file" ? it.filename ?? it.url : it.url}
</a>
{it.size != null && <span className="rb-dl-size">{formatSize(it.size)}</span>}
<button
type="button"
className="rb-dl-remove"
aria-label={`Remove ${it.label}`}
onClick={() => remove(i)}
>
</button>
</li>
))}
</ul>
)}
<div className="rb-dl-add">
<div className="rb-dl-add-row">
<input
type="text"
className="rb-dl-add-label"
value={linkLabel}
placeholder="Label (optional)"
onChange={(e) => setLinkLabel(e.target.value)}
/>
<input
type="url"
className="rb-dl-add-url"
value={linkUrl}
placeholder="https://example.com/file.zip"
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addLink();
}
}}
/>
<button type="button" className="rb-btn rb-btn-inline" onClick={addLink}>
Add link
</button>
</div>
<div className="rb-dl-add-row">
<input
ref={fileRef}
type="file"
multiple
className="rb-dl-file"
disabled={uploading}
onChange={(e) => onFiles(e.target.files)}
/>
{uploading && <span className="rb-dl-status">Uploading</span>}
</div>
{error && <p className="rb-admin-error rb-dl-error">{error}</p>}
</div>
</div>
);
}
@@ -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<HTMLTextAreaElement>(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 (
<div className="rb-mde">
<div className="rb-mde-bar">
<div className="rb-mde-tools">
{TOOLS.map((t, i) => (
<button
key={i}
type="button"
className="rb-mde-tool"
title={t.kind === "link" ? "Link" : t.kind === "image" ? "Image" : t.title}
onClick={() => onTool(t)}
disabled={tab === "preview"}
>
{t.kind === "link" ? "🔗" : t.kind === "image" ? "🖼" : t.label}
</button>
))}
</div>
<div className="rb-mde-tabs">
<button
type="button"
className={`rb-mde-tab ${tab === "write" ? "is-active" : ""}`}
onClick={() => setTab("write")}
>
Write
</button>
<button
type="button"
className={`rb-mde-tab ${tab === "preview" ? "is-active" : ""}`}
onClick={() => setTab("preview")}
>
Preview
</button>
</div>
</div>
{tab === "write" ? (
<textarea
ref={ref}
name={name}
className="rb-mono rb-mde-textarea"
rows={rows}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
) : (
<>
<div
className="rb-prose rb-mde-preview"
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
{/* Keep the value submittable even while previewing. */}
<input type="hidden" name={name} value={value} />
</>
)}
</div>
);
}
+15 -8
View File
@@ -3,6 +3,8 @@ import type { Post } from "@/lib/posts";
import { linksToText } from "@/lib/posts";
import { SOCIAL_NETWORKS } from "@/lib/integrations/social";
import { savePostAction } from "../../actions";
import MarkdownEditor from "./MarkdownEditor";
import DownloadEditor from "./DownloadEditor";
// Server-rendered create/edit form. Both modes post to the same action; the
// hidden `id` (present only when editing) decides insert vs update.
@@ -70,17 +72,22 @@ export default function PostForm({
<textarea name="excerpt" rows={2} defaultValue={post?.excerpt ?? ""} />
</label>
<label className="rb-field">
<div className="rb-field">
<span>
Body <em className="rb-admin-muted">(Markdown)</em>
</span>
<textarea
name="body"
rows={16}
className="rb-mono"
defaultValue={post?.body ?? ""}
/>
</label>
<MarkdownEditor name="body" defaultValue={post?.body ?? ""} />
</div>
<div className="rb-field">
<span>
Downloads{" "}
<em className="rb-admin-muted">
(upload a file to the blog, or add an external link)
</em>
</span>
<DownloadEditor name="downloads" initial={post?.downloads ?? []} />
</div>
<label className="rb-field">
<span>
+6
View File
@@ -85,6 +85,7 @@ function postInputFromForm(formData: FormData): PostInput {
author: s(formData, "author"),
tags: s(formData, "tags"),
links: s(formData, "links"),
downloads: s(formData, "downloads"),
createdAt: s(formData, "createdAt"),
};
}
@@ -180,6 +181,11 @@ export async function importAction(formData: FormData) {
: typeof p.tags === "string"
? p.tags
: "",
downloads: Array.isArray(p.downloads)
? JSON.stringify(p.downloads)
: typeof p.downloads === "string"
? p.downloads
: undefined,
createdAt:
typeof p.createdAt === "string"
? p.createdAt
+194
View File
@@ -625,3 +625,197 @@
background: var(--a-err-bg);
color: var(--a-danger);
}
/* ---- markdown editor (post body) ---- */
.rb-mde {
border: 1px solid var(--a-border);
border-radius: 6px;
overflow: hidden;
background: #fff;
}
.rb-mde-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
padding: 6px 8px;
background: #f6f8fa;
border-bottom: 1px solid var(--a-border);
}
.rb-mde-tools {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.rb-mde-tool {
font: inherit;
font-size: 13px;
min-width: 30px;
padding: 4px 7px;
color: var(--a-text);
background: #fff;
border: 1px solid var(--a-border);
border-radius: 5px;
cursor: pointer;
}
.rb-mde-tool:hover:not(:disabled) {
background: #eef1f6;
}
.rb-mde-tool:disabled {
opacity: 0.4;
cursor: default;
}
.rb-mde-tabs {
display: flex;
gap: 2px;
}
.rb-mde-tab {
font: inherit;
font-size: 13px;
padding: 4px 12px;
color: var(--a-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 5px;
cursor: pointer;
}
.rb-mde-tab.is-active {
color: var(--a-text);
background: #fff;
border-color: var(--a-border);
font-weight: 600;
}
.rb-mde-textarea {
display: block;
width: 100%;
border: 0 !important;
border-radius: 0 !important;
resize: vertical;
padding: 12px !important;
}
.rb-mde-textarea:focus {
outline: 2px solid var(--a-primary);
outline-offset: -2px;
}
.rb-mde-preview {
padding: 14px 16px;
min-height: 200px;
max-height: 520px;
overflow: auto;
}
/* ---- download editor ---- */
.rb-dl-list {
list-style: none;
margin: 0 0 12px;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.rb-dl-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid var(--a-border);
border-radius: 6px;
background: #fbfcfe;
}
.rb-dl-kind {
font-size: 16px;
flex: none;
}
.rb-dl-label {
flex: 1 1 40%;
min-width: 0;
font: inherit;
color: var(--a-text);
background: #fff;
border: 1px solid var(--a-border);
border-radius: 5px;
padding: 5px 8px;
}
.rb-dl-label:focus {
outline: 2px solid var(--a-primary);
outline-offset: -1px;
}
.rb-dl-url {
flex: 1 1 30%;
min-width: 0;
font-size: 12px;
color: var(--a-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rb-dl-size {
flex: none;
font-size: 12px;
color: var(--a-muted);
white-space: nowrap;
}
.rb-dl-remove {
flex: none;
font: inherit;
line-height: 1;
color: var(--a-muted);
background: transparent;
border: 0;
border-radius: 4px;
padding: 4px 7px;
cursor: pointer;
}
.rb-dl-remove:hover {
background: var(--a-err-bg);
color: var(--a-danger);
}
.rb-dl-add {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px dashed var(--a-border);
border-radius: 6px;
background: #f6f8fa;
}
.rb-dl-add-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.rb-dl-add-label {
flex: 1 1 160px;
}
.rb-dl-add-url {
flex: 2 1 240px;
}
.rb-dl-add-label,
.rb-dl-add-url {
font: inherit;
color: var(--a-text);
background: #fff;
border: 1px solid var(--a-border);
border-radius: 6px;
padding: 8px 10px;
min-width: 0;
}
.rb-dl-add-label:focus,
.rb-dl-add-url:focus {
outline: 2px solid var(--a-primary);
outline-offset: -1px;
}
.rb-dl-file {
font: inherit;
font-size: 13px;
flex: 1 1 auto;
}
.rb-dl-status {
color: var(--a-muted);
font-size: 13px;
}
.rb-dl-error {
margin: 0;
}
+1
View File
@@ -31,6 +31,7 @@ export async function GET(req: NextRequest) {
body: p.body,
author: p.author,
tags: p.tags,
downloads: p.downloads,
createdAt: p.createdAt,
}));
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse, type NextRequest } from "next/server";
import fs from "node:fs";
import path from "node:path";
import { isAdmin } from "@/lib/auth";
import { makeStoredName, uploadsDir } from "@/lib/uploads";
// POST /admin/upload — multipart with a single `file` field. Saves the file to
// data/uploads and returns its public URL + metadata for the download editor.
// Guarded by the /admin middleware, plus a check here for defence in depth.
// Cap uploads so a single request can't exhaust the disk. 200 MB suits ROMs /
// archives while staying sane for a self-hosted blog.
const MAX_BYTES = 200 * 1024 * 1024;
export async function POST(req: NextRequest) {
if (!(await isAdmin())) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
let form: FormData;
try {
form = await req.formData();
} catch {
return NextResponse.json({ error: "invalid form data" }, { status: 400 });
}
const file = form.get("file");
if (!(file instanceof File) || file.size === 0) {
return NextResponse.json({ error: "no file" }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: "file too large" }, { status: 413 });
}
const stored = makeStoredName(file.name || "file");
const dest = path.join(uploadsDir(), stored);
const buf = Buffer.from(await file.arrayBuffer());
fs.writeFileSync(dest, buf);
return NextResponse.json({
url: `/uploads/${stored}`,
filename: file.name || stored,
size: file.size,
});
}
+48
View File
@@ -227,6 +227,54 @@ img {
font-size: 1.1em;
}
/* per-post downloads section */
.rb-downloads {
margin-top: 26px;
padding-top: 16px;
border-top: 1px solid rgba(0, 0, 0, 0.15);
}
.rb-downloads-title {
margin: 0 0 10px;
font-size: 1.1em;
}
.rb-downloads-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.rb-download-link {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border: 1px solid currentColor;
border-radius: 6px;
text-decoration: none;
}
.rb-download-link:hover {
filter: brightness(0.96);
}
.rb-download-icon {
font-size: 1.2em;
flex: none;
}
.rb-download-label {
flex: 1;
font-weight: bold;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rb-download-size {
flex: none;
opacity: 0.7;
font-size: 0.85em;
}
/* per-post share footer */
.rb-article-share {
display: flex;
+27
View File
@@ -8,6 +8,7 @@ import {
getPostBySlug,
renderMarkdown,
formatDate,
formatSize,
} from "@/lib/posts";
export function generateStaticParams() {
@@ -48,6 +49,32 @@ export default async function PostPage({
className="rb-prose"
dangerouslySetInnerHTML={{ __html: html }}
/>
{post.downloads.length > 0 && (
<section className="rb-downloads">
<h2 className="rb-downloads-title">Downloads</h2>
<ul className="rb-downloads-list">
{post.downloads.map((d, i) => (
<li key={`${d.url}-${i}`} className="rb-download">
<a
className="rb-download-link"
href={d.url}
{...(d.kind === "file"
? { download: d.filename ?? "" }
: { target: "_blank", rel: "noreferrer" })}
>
<span className="rb-download-icon" aria-hidden>
{d.kind === "file" ? "📦" : "🔗"}
</span>
<span className="rb-download-label">{d.label}</span>
{d.size != null && (
<span className="rb-download-size">{formatSize(d.size)}</span>
)}
</a>
</li>
))}
</ul>
</section>
)}
{post.links.length > 0 && (
<footer className="rb-article-share">
<span className="rb-article-share-label">Shared on:</span>
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import fs from "node:fs";
import path from "node:path";
import { isSafeStoredName, mimeForName, uploadsDir } from "@/lib/uploads";
// GET /uploads/[name] — streams an uploaded attachment back to visitors. Public
// by design (downloads are meant to be downloaded); the name is validated so a
// crafted path can't escape the uploads directory.
export async function GET(
_req: Request,
{ params }: { params: Promise<{ name: string }> }
) {
const { name } = await params;
if (!isSafeStoredName(name)) {
return NextResponse.json({ error: "bad name" }, { status: 400 });
}
const file = path.join(uploadsDir(), name);
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
return NextResponse.json({ error: "not found" }, { status: 404 });
}
const data = fs.readFileSync(file);
return new NextResponse(new Uint8Array(data), {
headers: {
"Content-Type": mimeForName(name),
"Content-Length": String(data.length),
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}