96ea99b953
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>
162 lines
5.0 KiB
TypeScript
162 lines
5.0 KiB
TypeScript
"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>
|
|
);
|
|
}
|