Extend the wiki's RichTextEditor to the Posts editor and close the stored-XSS gap on public post bodies. - RichTextEditor: add `variant` prop — `full` (wiki), `post` (no internal wiki-page link picker), `minimal` (image-only, for Screenshots captions). Toolbar sections rendered conditionally. - PostEditor: replace the body textarea with a lazy-loaded RichTextEditor in Suspense; variant chosen by category (minimal for screenshots, post otherwise). - posts.model: sanitize body via shared cleanBody on create/update, treat an empty TipTap `<p></p>` as null, and auto-derive the excerpt from the body (max 280 chars) when left blank. - sanitizeHtml util: add deriveExcerpt() helper. - FiveOnFriday / NewsletterIssue: wrap dangerouslySetInnerHTML with DOMPurify.sanitize() as defense-in-depth on render. No schema or dependency changes. Verified end-to-end against the local stack: 24/24 API assertions and a full UI round-trip across all four post categories. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
63 lines
2.3 KiB
JavaScript
63 lines
2.3 KiB
JavaScript
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'
|
|
import { longDate, monthTile } from '../../lib/format.js'
|
|
import { api } from '../../api/client.js'
|
|
|
|
export default function NewsletterIssue() {
|
|
const { id } = useParams()
|
|
const { loading, error, data: issue } = useAsync(() => api.post('newsletter', id), [id])
|
|
|
|
return (
|
|
<PublicLayout section="website">
|
|
<div className="shell-narrow page-body">
|
|
{loading && <Loading />}
|
|
{error && (
|
|
<ErrorState
|
|
message={error.status === 404 ? 'That newsletter issue could not be found.' : 'Could not load this issue.'}
|
|
/>
|
|
)}
|
|
{issue && <Issue issue={issue} />}
|
|
{(error || issue) && (
|
|
<p style={{ marginTop: 34 }}>
|
|
<Link to="/site/newsletter" className="pill">
|
|
← All issues
|
|
</Link>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</PublicLayout>
|
|
)
|
|
}
|
|
|
|
function Issue({ issue }) {
|
|
const tile = monthTile(issue.published_at || issue.created_at)
|
|
const label = `${tile.mon} ${tile.num}`.trim()
|
|
return (
|
|
<article>
|
|
<p className="sans" style={{ margin: '0 0 14px', display: 'flex', gap: 8, color: 'var(--dim)', fontSize: '0.82rem' }}>
|
|
<Link to="/site/newsletter" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
|
Newsletter
|
|
</Link>
|
|
<span>/</span>
|
|
<span>{longDate(issue.published_at || issue.created_at)}</span>
|
|
</p>
|
|
<p className="eyebrow" style={{ letterSpacing: '0.16em' }}>
|
|
Issue — {label}
|
|
</p>
|
|
<h1 className="display" style={{ margin: 0, fontSize: 'clamp(2.2rem,5vw,3.2rem)', lineHeight: 1.05, color: 'var(--head)' }}>
|
|
{issue.title}
|
|
</h1>
|
|
{issue.excerpt && <p style={{ margin: '14px 0 0', color: 'var(--muted)', fontSize: '1.1rem' }}>{issue.excerpt}</p>}
|
|
<div style={{ height: 1, background: 'var(--line)', margin: '28px 0' }} />
|
|
{issue.body ? (
|
|
<div className="prose" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(issue.body) }} />
|
|
) : (
|
|
<p className="muted">This issue has no content yet.</p>
|
|
)}
|
|
</article>
|
|
)
|
|
}
|