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
@@ -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({
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 }) {
<ToolbarButton active={state?.underline} label="Underline" onClick={() => editor.chain().focus().toggleUnderline().run()}><u>U</u></ToolbarButton>
</div>
<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 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 className="editor-tool-group">
<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 { 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>}
+62
View File
@@ -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); }