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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user