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>
);
}