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
+3 -1
View File
@@ -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 }) {
<article className={`post-card post-card--${post.accent} ${featured ? "post-card--featured" : ""} reveal`}>
<Link to={`/blog/${post.slug}`} aria-label={`Read ${post.title}`}>
<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-code">{post.tags[0]}</span>
<Icon name="spark" size={featured ? 34 : 26} />
</>}
</div>
<div className="post-card__body">
<div className="post-meta">
+4 -1
View File
@@ -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 (
<article className="article-page">
<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-container reveal">
<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>
)) : <RichTextArticle content={post.content} />}
<ArticleAttachments attachments={post.attachments} />
<div className="article-end">
<Icon name="spark" />
<p>Thanks for reading.</p>
+41 -3
View File
@@ -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 (
<section className="content-page container publisher-page">
<PageHeader
@@ -255,12 +288,17 @@ export default function JournalAdminPage() {
<div className="publisher-field publisher-field--wide">
<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>
</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">
<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"}
</button>
{status.message && <p className={`form-status form-status--${status.type}`} role="status">{status.message}</p>}