Implemented:

- + Section button with consistent dividers and automatic uppercase drop caps.
- Banner uploads shown in journal thumbnails and above article titles.
- Additional photos and downloadable files displayed below article text.
- Upload previews and removal controls.
Verified publishing in a browser, mobile layout, backend tests, and production build. Supabase schema updated.
This commit is contained in:
StormRunner06106
2026-09-12 01:44:48 -07:00
parent 3ed741d6d2
commit 737b56e97e
9 changed files with 278 additions and 8 deletions
+8
View File
@@ -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. 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 ## Supabase article storage
The runtime backend needs two values from **Supabase Dashboard → Settings → API Keys**: The runtime backend needs two values from **Supabase Dashboard → Settings → API Keys**:
+4
View File
@@ -17,6 +17,10 @@ FRONTEND_ORIGINS=http://localhost:5173
JOURNAL_ADMIN_PASSWORD=replace-with-a-strong-password JOURNAL_ADMIN_PASSWORD=replace-with-a-strong-password
JOURNAL_TOKEN_SECRET=replace-with-a-long-random-secret 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. # Server-only Supabase article storage. Prefer the new sb_secret_ key.
SUPABASE_URL=https://your-project-ref.supabase.co SUPABASE_URL=https://your-project-ref.supabase.co
SUPABASE_SECRET_KEY=sb_secret_replace_me SUPABASE_SECRET_KEY=sb_secret_replace_me
+84
View File
@@ -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"<script>alert(1)</script>", 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()
@@ -0,0 +1,55 @@
import { mediaUrl } from "../api";
export default function ArticleUploads({ banner, attachments, busy, onUpload, onRemoveBanner, onRemoveAttachment }) {
return (
<fieldset className="article-uploads" disabled={busy}>
<legend>Photos & files</legend>
<label className="publisher-field">
<span>Banner image</span>
<small>Shown on the journal thumbnail and above the article title. JPEG, PNG, WebP or GIF, up to 8 MB.</small>
<input type="file" accept="image/jpeg,image/png,image/webp,image/gif" onChange={(event) => {
onUpload(Array.from(event.target.files), "banner");
event.target.value = "";
}} />
</label>
{banner && <div className="upload-preview">
<img src={mediaUrl(banner)} alt="Banner preview" />
<span>{banner.name}</span>
<button type="button" className="text-button" onClick={onRemoveBanner}>Remove banner</button>
</div>}
<label className="publisher-field">
<span>Additional photos & files</span>
<small>Displayed below the article text, in upload order. Up to 10 files, 20 MB each.</small>
<input type="file" multiple onChange={(event) => {
onUpload(Array.from(event.target.files), "attachment");
event.target.value = "";
}} />
</label>
{attachments.length > 0 && <ul className="upload-list">
{attachments.map((file) => <li key={file.url}>
{file.media_type.startsWith("image/") && <img src={mediaUrl(file)} alt="" />}
<span>{file.name} <small>({Math.ceil(file.size / 1024)} KB)</small></span>
<button type="button" className="text-button" aria-label={`Remove ${file.name}`} onClick={() => onRemoveAttachment(file.url)}>Remove</button>
</li>)}
</ul>}
{busy && <p role="status">Uploading</p>}
</fieldset>
);
}
export function ArticleAttachments({ attachments = [] }) {
if (!attachments.length) return null;
return <aside className="article-attachments" aria-label="Additional photos and files">
<h2>Photos & files</h2>
{attachments.map((file) => file.media_type.startsWith("image/") ? (
<figure key={file.url}>
<a href={mediaUrl(file)} target="_blank" rel="noreferrer"><img src={mediaUrl(file)} alt={file.name} loading="lazy" /></a>
<figcaption>{file.name}</figcaption>
</figure>
) : (
<a className="article-file" key={file.url} href={mediaUrl(file)} download={file.name}>
<span>{file.name}</span><small>Download · {Math.ceil(file.size / 1024)} KB</small>
</a>
))}
</aside>;
}
+17 -3
View File
@@ -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({ const editor = useEditor({
extensions, extensions,
content: "", content,
editorProps: { attributes: { "aria-label": "Article body" } }, editorProps: { attributes: { "aria-label": "Article body" } },
onUpdate: ({ editor: currentEditor }) => { onUpdate: ({ editor: currentEditor }) => {
onChange(currentEditor.getJSON(), currentEditor.getText().trim()); onChange(currentEditor.getJSON(), currentEditor.getText().trim());
@@ -66,8 +66,22 @@ export default function RichTextEditor({ onChange }) {
<ToolbarButton active={state?.underline} label="Underline" onClick={() => editor.chain().focus().toggleUnderline().run()}><u>U</u></ToolbarButton> <ToolbarButton active={state?.underline} label="Underline" onClick={() => editor.chain().focus().toggleUnderline().run()}><u>U</u></ToolbarButton>
</div> </div>
<div className="editor-tool-group"> <div className="editor-tool-group">
<ToolbarButton active={state?.heading2} label="Heading" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</ToolbarButton> <ToolbarButton active={state?.heading2} label="Section heading" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>Section heading</ToolbarButton>
<ToolbarButton active={state?.heading3} label="Subheading" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</ToolbarButton> <ToolbarButton active={state?.heading3} label="Subheading" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</ToolbarButton>
<ToolbarButton label="Add section" onClick={() => {
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</ToolbarButton>
</div> </div>
<div className="editor-tool-group"> <div className="editor-tool-group">
<ToolbarButton active={state?.bulletList} label="Bullet list" onClick={() => editor.chain().focus().toggleBulletList().run()}> List</ToolbarButton> <ToolbarButton active={state?.bulletList} label="Bullet list" onClick={() => editor.chain().focus().toggleBulletList().run()}> List</ToolbarButton>
+3 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { getPosts } from "../api"; import { getPosts, mediaUrl } from "../api";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import PageHeader from "../components/PageHeader"; import PageHeader from "../components/PageHeader";
import { ErrorState, LoadingState } from "../components/Status"; import { ErrorState, LoadingState } from "../components/Status";
@@ -18,9 +18,11 @@ function PostCard({ post, featured }) {
<article className={`post-card post-card--${post.accent} ${featured ? "post-card--featured" : ""} reveal`}> <article className={`post-card post-card--${post.accent} ${featured ? "post-card--featured" : ""} reveal`}>
<Link to={`/blog/${post.slug}`} aria-label={`Read ${post.title}`}> <Link to={`/blog/${post.slug}`} aria-label={`Read ${post.title}`}>
<div className="post-card__art" aria-hidden="true"> <div className="post-card__art" aria-hidden="true">
{post.banner ? <img className="post-card__image" src={mediaUrl(post.banner)} alt="" loading="lazy" /> : <>
<span className="art-ring" /> <span className="art-ring" />
<span className="art-code">{post.tags[0]}</span> <span className="art-code">{post.tags[0]}</span>
<Icon name="spark" size={featured ? 34 : 26} /> <Icon name="spark" size={featured ? 34 : 26} />
</>}
</div> </div>
<div className="post-card__body"> <div className="post-card__body">
<div className="post-meta"> <div className="post-meta">
+4 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; 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 Icon from "../components/Icon";
import { RichTextArticle } from "../components/RichTextEditor"; import { RichTextArticle } from "../components/RichTextEditor";
import { ErrorState, LoadingState } from "../components/Status"; import { ErrorState, LoadingState } from "../components/Status";
@@ -51,6 +52,7 @@ export default function BlogPostPage() {
return ( return (
<article className="article-page"> <article className="article-page">
<header className={`article-hero article-hero--${post.accent}`}> <header className={`article-hero article-hero--${post.accent}`}>
{post.banner && <img className="article-banner" src={mediaUrl(post.banner)} alt="" />}
<div className="article-hero__shape" aria-hidden="true"><Icon name="spark" size={50} /></div> <div className="article-hero__shape" aria-hidden="true"><Icon name="spark" size={50} /></div>
<div className="article-container reveal"> <div className="article-container reveal">
<Link className="back-link" to="/blog"><Icon name="arrowLeft" size={17} /> Back to journal</Link> <Link className="back-link" to="/blog"><Icon name="arrowLeft" size={17} /> Back to journal</Link>
@@ -74,6 +76,7 @@ export default function BlogPostPage() {
{section.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)} {section.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
</section> </section>
)) : <RichTextArticle content={post.content} />} )) : <RichTextArticle content={post.content} />}
<ArticleAttachments attachments={post.attachments} />
<div className="article-end"> <div className="article-end">
<Icon name="spark" /> <Icon name="spark" />
<p>Thanks for reading.</p> <p>Thanks for reading.</p>
+41 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { createPost, getAdminSession, loginAdmin } from "../api"; import { createPost, getAdminSession, loginAdmin, uploadMedia } from "../api";
import ArticleUploads from "../components/ArticleUploads";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import PageHeader from "../components/PageHeader"; import PageHeader from "../components/PageHeader";
import RichTextEditor from "../components/RichTextEditor"; import RichTextEditor from "../components/RichTextEditor";
@@ -39,6 +40,9 @@ export default function JournalAdminPage() {
const [post, setPost] = useState(initialPost); const [post, setPost] = useState(initialPost);
const [article, setArticle] = useState(emptyDocument); const [article, setArticle] = useState(emptyDocument);
const [articleText, setArticleText] = useState(""); const [articleText, setArticleText] = useState("");
const [banner, setBanner] = useState(null);
const [attachments, setAttachments] = useState([]);
const [uploading, setUploading] = useState(false);
const [customTopic, setCustomTopic] = useState(""); const [customTopic, setCustomTopic] = useState("");
const [status, setStatus] = useState({ type: "idle", message: "" }); const [status, setStatus] = useState({ type: "idle", message: "" });
@@ -113,6 +117,7 @@ export default function JournalAdminPage() {
async function publish(event) { async function publish(event) {
event.preventDefault(); event.preventDefault();
if (uploading || status.type === "sending") return;
if (!post.tags.length) { if (!post.tags.length) {
setStatus({ type: "error", message: "Choose at least one topic." }); setStatus({ type: "error", message: "Choose at least one topic." });
return; return;
@@ -126,6 +131,8 @@ export default function JournalAdminPage() {
...post, ...post,
read_time: Number(post.read_time), read_time: Number(post.read_time),
content: article, content: article,
banner,
attachments,
}; };
setStatus({ type: "sending", message: "Publishing…" }); setStatus({ type: "sending", message: "Publishing…" });
@@ -142,6 +149,32 @@ export default function JournalAdminPage() {
} }
} }
async function uploadFiles(files, purpose) {
if (!files.length || uploading) return;
if (purpose === "attachment" && attachments.length + files.length > 10) {
setStatus({ type: "error", message: "Choose up to 10 additional files." });
return;
}
const limit = (purpose === "banner" ? 8 : 20) * 1024 * 1024;
if (files.some((file) => !file.size || file.size > limit)) {
setStatus({ type: "error", message: `Choose non-empty files up to ${limit / 1024 / 1024} MB each.` });
return;
}
setUploading(true);
setStatus({ type: "idle", message: "" });
try {
for (const file of files) {
const uploaded = await uploadMedia(file, purpose, token);
if (purpose === "banner") setBanner(uploaded);
else setAttachments((current) => [...current, uploaded]);
}
} catch (error) {
setStatus({ type: "error", message: error.message });
} finally {
setUploading(false);
}
}
return ( return (
<section className="content-page container publisher-page"> <section className="content-page container publisher-page">
<PageHeader <PageHeader
@@ -255,12 +288,17 @@ export default function JournalAdminPage() {
<div className="publisher-field publisher-field--wide"> <div className="publisher-field publisher-field--wide">
<span>Article</span> <span>Article</span>
<RichTextEditor onChange={(content, text) => { setArticle(content); setArticleText(text); }} /> <RichTextEditor content={article} onChange={(content, text) => { setArticle(content); setArticleText(text); }} />
<small>{articleText.length} characters · Use headings to break longer pieces into sections.</small> <small>{articleText.length} characters · Use headings to break longer pieces into sections.</small>
</div> </div>
<p className="section-help">Use + Section to add a heading and a new section. Each section gets a divider and an automatic uppercase drop cap on its opening paragraph.</p>
<ArticleUploads banner={banner} attachments={attachments} busy={uploading || status.type === "sending"}
onUpload={uploadFiles} onRemoveBanner={() => setBanner(null)}
onRemoveAttachment={(url) => setAttachments((current) => current.filter((file) => file.url !== url))} />
<div className="publisher-submit"> <div className="publisher-submit">
<button className="button button--primary" disabled={status.type === "sending"} type="submit"> <button className="button button--primary" disabled={uploading || status.type === "sending"} type="submit">
<Icon name="plus" size={18} /> {status.type === "sending" ? "Publishing…" : "Publish article"} <Icon name="plus" size={18} /> {status.type === "sending" ? "Publishing…" : "Publish article"}
</button> </button>
{status.message && <p className={`form-status form-status--${status.type}`} role="status">{status.message}</p>} {status.message && <p className={`form-status form-status--${status.type}`} role="status">{status.message}</p>}
+62
View File
@@ -1416,6 +1416,7 @@ button {
color: var(--sage-deep); color: var(--sage-deep);
font-size: 51px; font-size: 51px;
line-height: 0.72; line-height: 0.72;
text-transform: uppercase;
} }
.article-end { .article-end {
@@ -2204,6 +2205,24 @@ button {
.rich-article .tiptap h2 { margin: 55px 0 20px; font-size: clamp(30px, 4vw, 42px); } .rich-article .tiptap h2 { margin: 55px 0 20px; font-size: clamp(30px, 4vw, 42px); }
.rich-article .tiptap h2:first-child { margin-top: 0; } .rich-article .tiptap h2:first-child { margin-top: 0; }
.rich-article .tiptap > h2:not(:first-child),
.rich-editor .tiptap > h2:not(:first-child) {
margin-top: 55px;
padding-top: 48px;
border-top: 1px solid var(--line);
}
.rich-article .tiptap > p:first-child::first-letter,
.rich-article .tiptap > h2 + p::first-letter,
.rich-editor .tiptap > p:first-child:not(.is-editor-empty)::first-letter,
.rich-editor .tiptap > h2 + p::first-letter {
float: left;
margin: 7px 8px 0 0;
color: var(--sage-deep);
font-family: Georgia, "Times New Roman", serif;
font-size: 51px;
line-height: 0.72;
text-transform: uppercase;
}
.rich-article .tiptap h3 { margin: 38px 0 16px; font-size: clamp(24px, 3vw, 32px); } .rich-article .tiptap h3 { margin: 38px 0 16px; font-size: clamp(24px, 3vw, 32px); }
.rich-article .tiptap p { margin: 0 0 24px; } .rich-article .tiptap p { margin: 0 0 24px; }
.rich-article .tiptap ul, .rich-article .tiptap ul,
@@ -2687,3 +2706,46 @@ button {
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
/* Article media shares the same source in cards, the title banner, and previews. */
.post-card__image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.article-hero:has(.article-banner) { padding-top: 0; }
.article-banner {
display: block;
width: 100%;
height: clamp(220px, 38vw, 500px);
object-fit: cover;
margin-bottom: clamp(36px, 6vw, 72px);
position: relative;
z-index: 1;
}
.article-hero:has(.article-banner) .article-hero__shape { display: none; }
.article-uploads {
display: grid;
gap: 24px;
min-width: 0;
padding: 24px;
border: 1px solid var(--line);
border-radius: 18px;
}
.article-uploads legend { padding-inline: 8px; }
.article-uploads small, .section-help { color: var(--ink-soft); line-height: 1.6; }
.article-uploads input[type="file"] { max-width: 100%; padding: 12px 0; }
.upload-preview { display: grid; gap: 12px; justify-items: start; overflow-wrap: anywhere; }
.upload-preview img { width: 100%; max-height: 240px; object-fit: cover; border-radius: 12px; }
.upload-list { display: grid; gap: 12px; list-style: none; margin: 0; padding: 0; }
.upload-list li { display: flex; align-items: center; gap: 14px; }
.upload-list li > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.upload-list img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; }
.article-attachments { margin-top: 55px; padding-top: 48px; border-top: 1px solid var(--line); }
.article-attachments figure { margin: 24px 0; }
.article-attachments img { display: block; max-width: 100%; height: auto; margin-inline: auto; border-radius: 12px; }
.article-attachments figcaption { margin-top: 10px; color: var(--ink-soft); font-size: 14px; overflow-wrap: anywhere; }
.article-file { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; padding: 20px; margin-block: 12px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; overflow-wrap: anywhere; }
.article-file small { color: var(--ink-soft); }