From 737b56e97e802c74ffa606f0ed2131000ca1a902 Mon Sep 17 00:00:00 2001 From: StormRunner06106 Date: Sat, 12 Sep 2026 01:44:48 -0700 Subject: [PATCH] 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. --- README.md | 8 +++ backend/.env.example | 4 ++ backend/test_articles.py | 84 ++++++++++++++++++++++ frontend/src/components/ArticleUploads.jsx | 55 ++++++++++++++ frontend/src/components/RichTextEditor.jsx | 20 +++++- frontend/src/pages/BlogPage.jsx | 4 +- frontend/src/pages/BlogPostPage.jsx | 5 +- frontend/src/pages/JournalAdminPage.jsx | 44 +++++++++++- frontend/src/styles.css | 62 ++++++++++++++++ 9 files changed, 278 insertions(+), 8 deletions(-) create mode 100644 backend/test_articles.py create mode 100644 frontend/src/components/ArticleUploads.jsx 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 ( +
+ Photos & files + + {banner &&
+ Banner preview + {banner.name} + +
} + + {attachments.length > 0 && } + {busy &&

Uploading…

} +
+ ); +} + +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 }) {
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 (
+ {post.banner && }
Back to journal @@ -74,6 +76,7 @@ export default function BlogPostPage() { {section.paragraphs.map((paragraph) =>

{paragraph}

)} )) : } +

Thanks for reading.

diff --git a/frontend/src/pages/JournalAdminPage.jsx b/frontend/src/pages/JournalAdminPage.jsx index ed9fe80..514af6f 100644 --- a/frontend/src/pages/JournalAdminPage.jsx +++ b/frontend/src/pages/JournalAdminPage.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; 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 PageHeader from "../components/PageHeader"; import RichTextEditor from "../components/RichTextEditor"; @@ -39,6 +40,9 @@ export default function JournalAdminPage() { const [post, setPost] = useState(initialPost); const [article, setArticle] = useState(emptyDocument); const [articleText, setArticleText] = useState(""); + const [banner, setBanner] = useState(null); + const [attachments, setAttachments] = useState([]); + const [uploading, setUploading] = useState(false); const [customTopic, setCustomTopic] = useState(""); const [status, setStatus] = useState({ type: "idle", message: "" }); @@ -113,6 +117,7 @@ export default function JournalAdminPage() { async function publish(event) { event.preventDefault(); + if (uploading || status.type === "sending") return; if (!post.tags.length) { setStatus({ type: "error", message: "Choose at least one topic." }); return; @@ -126,6 +131,8 @@ export default function JournalAdminPage() { ...post, read_time: Number(post.read_time), content: article, + banner, + attachments, }; 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 (
Article - { setArticle(content); setArticleText(text); }} /> + { setArticle(content); setArticleText(text); }} /> {articleText.length} characters · Use headings to break longer pieces into sections.
+

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.

+ setBanner(null)} + onRemoveAttachment={(url) => setAttachments((current) => current.filter((file) => file.url !== url))} /> +
- {status.message &&

{status.message}

} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index ce35a51..e646e26 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1416,6 +1416,7 @@ button { color: var(--sage-deep); font-size: 51px; line-height: 0.72; + text-transform: uppercase; } .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: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 p { margin: 0 0 24px; } .rich-article .tiptap ul, @@ -2687,3 +2706,46 @@ button { 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); }