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>
|
||||
);
|
||||
}
|
||||
@@ -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 = ``;
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user