diff --git a/client/src/components/RichTextEditor.jsx b/client/src/components/RichTextEditor.jsx index e9289f4..0f81773 100644 --- a/client/src/components/RichTextEditor.jsx +++ b/client/src/components/RichTextEditor.jsx @@ -25,7 +25,13 @@ function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]) } -export default function RichTextEditor({ value, onChange, pages = [] }) { +// Toolbar variants: +// 'full' — every control, incl. the internal wiki-page link picker (wiki use). +// 'post' — full minus the wiki-page picker (no page-list context in posts). +// 'minimal' — image upload only; text formatting stripped (Screenshots captions). +export default function RichTextEditor({ value, onChange, pages = [], variant = 'full' }) { + const showText = variant !== 'minimal' // bold/italic/strike, headings, lists, quotes, links + const showWikiLink = variant === 'full' && pages.length > 0 const fileRef = useRef(null) const [uploading, setUploading] = useState(false) const [linkMenu, setLinkMenu] = useState(false) @@ -90,45 +96,51 @@ export default function RichTextEditor({ value, onChange, pages = [] }) { return (
{it.excerpt}
)} diff --git a/client/src/routes/public/NewsletterIssue.jsx b/client/src/routes/public/NewsletterIssue.jsx index 36fe976..116169d 100644 --- a/client/src/routes/public/NewsletterIssue.jsx +++ b/client/src/routes/public/NewsletterIssue.jsx @@ -1,4 +1,5 @@ import { Link, useParams } from 'react-router-dom' +import DOMPurify from 'dompurify' import PublicLayout from '../../components/PublicLayout.jsx' import { Loading, ErrorState } from '../../components/PageState.jsx' import { useAsync } from '../../lib/useAsync.js' @@ -52,7 +53,7 @@ function Issue({ issue }) { {issue.excerpt &&{issue.excerpt}
} {issue.body ? ( - + ) : (This issue has no content yet.
)} diff --git a/server/src/model/posts/posts.model.js b/server/src/model/posts/posts.model.js index 77bcb0a..fb1e323 100644 --- a/server/src/model/posts/posts.model.js +++ b/server/src/model/posts/posts.model.js @@ -1,4 +1,13 @@ const postsDb = require('./posts.db') +const { cleanBody, deriveExcerpt } = require('../../utils/sanitizeHtml') + +// Sanitize body HTML and treat an empty editor (TipTap emits ``) as null +// so we never store a meaningless empty paragraph. +function normalizeBody(body) { + const clean = cleanBody(body) + if (clean == null) return null + return String(clean).trim() === '' ? null : clean +} // URL category (kebab) <-> DB enum value. const CATEGORY_MAP = { @@ -40,12 +49,22 @@ async function getById(id) { } async function create(post) { - const id = await postsDb.insert(post) + const body = normalizeBody(post.body) + // Auto-fill the excerpt from the first of the body when left blank. + const excerpt = post.excerpt && String(post.excerpt).trim() ? post.excerpt : deriveExcerpt(body) + const id = await postsDb.insert({ ...post, body, excerpt: excerpt || null }) return postsDb.findById(id) } async function update(id, fields) { - await postsDb.update(id, fields) + const next = { ...fields } + if ('body' in next) next.body = normalizeBody(next.body) + // If the body is being updated and no non-empty excerpt was supplied, + // derive one from the new body. + if ('body' in next && !(next.excerpt && String(next.excerpt).trim())) { + next.excerpt = deriveExcerpt(next.body) || null + } + await postsDb.update(id, next) return postsDb.findById(id) } diff --git a/server/src/utils/sanitizeHtml.js b/server/src/utils/sanitizeHtml.js index 7044e79..15d57ee 100644 --- a/server/src/utils/sanitizeHtml.js +++ b/server/src/utils/sanitizeHtml.js @@ -42,4 +42,22 @@ function cleanBody(html) { return sanitizeHtml(String(html), OPTIONS) } -module.exports = { cleanBody, OPTIONS } +/** + * Derive a plain-text excerpt from body HTML. Strips tags, collapses + * whitespace, and truncates to `max` chars (with an ellipsis). Used as the + * excerpt fallback when an author leaves the excerpt field blank. + * @param {string|null|undefined} html + * @param {number} [max=280] + * @returns {string|null} + */ +function deriveExcerpt(html, max = 280) { + if (html == null) return null + const text = String(html) + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim() + if (!text) return null + return text.length > max ? `${text.slice(0, max - 3)}...` : text +} + +module.exports = { cleanBody, deriveExcerpt, OPTIONS }