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:
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user