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:
@@ -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**:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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