diff --git a/README.md b/README.md
index d75fc3c..06e5b70 100644
--- a/README.md
+++ b/README.md
@@ -69,6 +69,14 @@ JOURNAL_TOKEN_SECRET=replace-with-a-long-random-secret
The login endpoint returns a signed session that expires after four hours. The password remains server-side and the browser stores only the temporary token.
+Use **+ Section** in the editor to add a section heading and opening paragraph. Section headings receive the same divider and uppercase drop cap as the starter articles, with formatting visible in the editor. Existing H2 headings use this styling automatically.
+
+The publisher accepts a banner image (JPEG, PNG, WebP, or GIF, up to 8 MB) and up to ten additional photos or files (20 MB each). The banner appears in the journal thumbnail and above the article title; additional photos and downloadable files appear below the body. Uploads require an admin session. File contents are saved in `backend/data/uploads` in both article storage modes; set `JOURNAL_UPLOAD_DIR` to a persistent mounted directory in production and include it in backups. Uploaded files are publicly accessible by their generated URLs. Removing a selection from an unpublished draft does not delete its stored upload.
+
+For an existing Supabase project, run `bash scripts/setup_supabase.sh --schema-only` to add the nullable `banner` and default-empty `attachments` columns before running the updated backend. Existing articles remain compatible.
+
+Verify article publishing and uploads with `.venv/Scripts/python.exe -m unittest backend.test_articles`.
+
## Supabase article storage
The runtime backend needs two values from **Supabase Dashboard → Settings → API Keys**:
diff --git a/backend/.env.example b/backend/.env.example
index 112f8bb..dff117e 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -17,6 +17,10 @@ FRONTEND_ORIGINS=http://localhost:5173
JOURNAL_ADMIN_PASSWORD=replace-with-a-strong-password
JOURNAL_TOKEN_SECRET=replace-with-a-long-random-secret
+# Persistent upload directory; defaults to backend/data/uploads.
+# Mount durable storage here when deploying to a host with an ephemeral filesystem.
+# JOURNAL_UPLOAD_DIR=/var/lib/portfolio/uploads
+
# Server-only Supabase article storage. Prefer the new sb_secret_ key.
SUPABASE_URL=https://your-project-ref.supabase.co
SUPABASE_SECRET_KEY=sb_secret_replace_me
diff --git a/backend/test_articles.py b/backend/test_articles.py
new file mode 100644
index 0000000..53803f7
--- /dev/null
+++ b/backend/test_articles.py
@@ -0,0 +1,84 @@
+import io
+import json
+import os
+from pathlib import Path
+from tempfile import TemporaryDirectory
+import unittest
+from unittest.mock import patch
+
+from fastapi.testclient import TestClient
+from PIL import Image
+
+from backend import main
+
+
+class ArticleMediaTests(unittest.TestCase):
+ def setUp(self):
+ self.directory = TemporaryDirectory()
+ self.addCleanup(self.directory.cleanup)
+ root = Path(self.directory.name)
+ (root / "posts.json").write_text("[]", encoding="utf-8")
+ for mocked in (
+ patch.object(main, "DATA_DIR", root),
+ patch.object(main, "POSTS_PATH", root / "posts.json"),
+ patch.object(main, "UPLOAD_DIR", root / "uploads"),
+ patch.object(main, "get_supabase", return_value=None),
+ patch.dict(os.environ, {"JOURNAL_ADMIN_PASSWORD": "test-password", "JOURNAL_TOKEN_SECRET": "test-secret"}),
+ ):
+ mocked.start()
+ self.addCleanup(mocked.stop)
+ main.read_data.cache_clear()
+ self.addCleanup(main.read_data.cache_clear)
+ self.client = TestClient(main.app)
+ token = self.client.post("/api/auth/login", json={"password": "test-password"}).json()["access_token"]
+ self.headers = {"Authorization": f"Bearer {token}"}
+
+ def upload(self, data, purpose="attachment", name="photo.png"):
+ return self.client.post("/api/uploads", params={"name": name, "purpose": purpose}, content=data, headers=self.headers)
+
+ def test_media_survives_publish_and_can_be_retrieved(self):
+ image = io.BytesIO()
+ Image.new("RGB", (20, 20), "green").save(image, format="PNG")
+ banner_response = self.upload(image.getvalue(), "banner")
+ self.assertEqual(banner_response.status_code, 201)
+ banner = banner_response.json()
+ attachment = self.upload(b"Supporting notes", name="notes.txt").json()
+ document = {"type": "doc", "content": [
+ {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "First section"}]},
+ {"type": "paragraph", "content": [{"type": "text", "text": "an opening paragraph with enough text to publish this article."}]},
+ {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Second section"}]},
+ {"type": "paragraph", "content": [{"type": "text", "text": "the next section keeps its separate heading and body."}]},
+ ]}
+ response = self.client.post("/api/posts", headers=self.headers, json={
+ "title": "An article with media", "excerpt": "A useful introduction to this test article.",
+ "published_at": "2026-09-12", "read_time": 3, "tags": ["AI"],
+ "content": document, "banner": banner, "attachments": [attachment],
+ })
+ self.assertEqual(response.status_code, 201, response.text)
+ post = self.client.get("/api/posts/an-article-with-media").json()
+ self.assertEqual(post["content"], document)
+ self.assertEqual(post["attachments"], [attachment])
+ self.assertEqual(self.client.get("/api/posts").json()[0]["banner"], banner)
+ self.assertEqual(json.loads(main.POSTS_PATH.read_text())[0]["banner"], banner)
+ photo = self.client.get(banner["url"])
+ self.assertEqual(photo.content, image.getvalue())
+ self.assertEqual(photo.headers["content-type"], "image/png")
+ file = self.client.get(attachment["url"])
+ self.assertIn("attachment", file.headers["content-disposition"])
+ self.assertEqual(file.content, b"Supporting notes")
+
+ def test_invalid_uploads_and_unauthenticated_requests(self):
+ self.assertEqual(self.client.post("/api/uploads?name=a.txt", content=b"test").status_code, 401)
+ self.assertEqual(self.upload(b"not a photo", "banner").status_code, 422)
+ self.assertEqual(self.upload(b"").status_code, 422)
+ self.assertEqual(self.upload(b"x" * (8 * 1024 * 1024 + 1), "banner").status_code, 413)
+ self.assertEqual(self.client.get("/api/uploads/invalid").status_code, 404)
+ unsafe = self.upload(b"", name="page.html").json()
+ downloaded = self.client.get(unsafe["url"])
+ self.assertEqual(downloaded.headers["content-type"], "application/octet-stream")
+ self.assertEqual(downloaded.headers["x-content-type-options"], "nosniff")
+ self.assertIn("attachment", downloaded.headers["content-disposition"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/frontend/src/components/ArticleUploads.jsx b/frontend/src/components/ArticleUploads.jsx
new file mode 100644
index 0000000..247223e
--- /dev/null
+++ b/frontend/src/components/ArticleUploads.jsx
@@ -0,0 +1,55 @@
+import { mediaUrl } from "../api";
+
+export default function ArticleUploads({ banner, attachments, busy, onUpload, onRemoveBanner, onRemoveAttachment }) {
+ return (
+
+ );
+}
+
+export function ArticleAttachments({ attachments = [] }) {
+ if (!attachments.length) return null;
+ return ;
+}
diff --git a/frontend/src/components/RichTextEditor.jsx b/frontend/src/components/RichTextEditor.jsx
index 5ab3f3d..b4debf5 100644
--- a/frontend/src/components/RichTextEditor.jsx
+++ b/frontend/src/components/RichTextEditor.jsx
@@ -31,10 +31,10 @@ function ToolbarButton({ active = false, children, label, onClick }) {
);
}
-export default function RichTextEditor({ onChange }) {
+export default function RichTextEditor({ onChange, content = "" }) {
const editor = useEditor({
extensions,
- content: "",
+ content,
editorProps: { attributes: { "aria-label": "Article body" } },
onUpdate: ({ editor: currentEditor }) => {
onChange(currentEditor.getJSON(), currentEditor.getText().trim());
@@ -66,8 +66,22 @@ export default function RichTextEditor({ onChange }) {
editor.chain().focus().toggleUnderline().run()}>U
- editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2
+ editor.chain().focus().toggleHeading({ level: 2 }).run()}>Section heading
editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3
+ {
+ if (editor.isEmpty) {
+ editor.chain().focus().setContent({ type: "doc", content: [
+ { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Section title" }] },
+ { type: "paragraph" },
+ ] }).setTextSelection({ from: 1, to: 14 }).run();
+ return;
+ }
+ const end = editor.state.doc.content.size;
+ editor.chain().focus().insertContentAt(end, [
+ { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Section title" }] },
+ { type: "paragraph" },
+ ]).setTextSelection({ from: end + 1, to: end + 14 }).run();
+ }}>+ Section
editor.chain().focus().toggleBulletList().run()}>• List
diff --git a/frontend/src/pages/BlogPage.jsx b/frontend/src/pages/BlogPage.jsx
index 892a697..2816ff4 100644
--- a/frontend/src/pages/BlogPage.jsx
+++ b/frontend/src/pages/BlogPage.jsx
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
-import { getPosts } from "../api";
+import { getPosts, mediaUrl } from "../api";
import Icon from "../components/Icon";
import PageHeader from "../components/PageHeader";
import { ErrorState, LoadingState } from "../components/Status";
@@ -18,9 +18,11 @@ function PostCard({ post, featured }) {
+ {post.banner ?
})
: <>
{post.tags[0]}
+ >}
diff --git a/frontend/src/pages/BlogPostPage.jsx b/frontend/src/pages/BlogPostPage.jsx
index 8ed4b57..8eaa25f 100644
--- a/frontend/src/pages/BlogPostPage.jsx
+++ b/frontend/src/pages/BlogPostPage.jsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
-import { getPost } from "../api";
+import { getPost, mediaUrl } from "../api";
+import { ArticleAttachments } from "../components/ArticleUploads";
import Icon from "../components/Icon";
import { RichTextArticle } from "../components/RichTextEditor";
import { ErrorState, LoadingState } from "../components/Status";
@@ -51,6 +52,7 @@ export default function BlogPostPage() {
return (