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>
176 lines
6.0 KiB
JavaScript
176 lines
6.0 KiB
JavaScript
import { lazy, Suspense, useState } from 'react'
|
|
import Modal from '../../../components/Modal.jsx'
|
|
import { api } from '../../../api/client.js'
|
|
|
|
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
|
|
|
|
const CATEGORIES = [
|
|
{ v: 'news', l: 'News' },
|
|
{ v: 'five-on-friday', l: 'Five on Friday' },
|
|
{ v: 'newsletter', l: 'Newsletter' },
|
|
{ v: 'screenshots', l: 'Screenshots' },
|
|
]
|
|
const DB_TO_URL = { news: 'news', five_on_friday: 'five-on-friday', newsletter: 'newsletter', screenshot: 'screenshots' }
|
|
|
|
export default function PostEditor({ post, onClose, onSaved }) {
|
|
const isEdit = Boolean(post)
|
|
const [form, setForm] = useState({
|
|
category: post ? DB_TO_URL[post.category] || 'news' : 'news',
|
|
title: post?.title || '',
|
|
slug: post?.slug || '',
|
|
excerpt: post?.excerpt || '',
|
|
body: post?.body || '',
|
|
image_url: post?.image_url || '',
|
|
published: post ? Boolean(post.published) : false,
|
|
})
|
|
const [busy, setBusy] = useState(false)
|
|
const [uploading, setUploading] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }))
|
|
const isScreenshot = form.category === 'screenshots'
|
|
|
|
async function onUpload(e) {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
setUploading(true)
|
|
setError('')
|
|
try {
|
|
const res = await api.admin.uploadImage(file)
|
|
setForm((f) => ({ ...f, image_url: res.image_url }))
|
|
} catch (err) {
|
|
setError(err.message || 'Upload failed')
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
if (!form.title.trim()) return setError('Title is required.')
|
|
if (isScreenshot && !form.image_url) return setError('Screenshots need an image.')
|
|
setBusy(true)
|
|
setError('')
|
|
const payload = {
|
|
category: form.category,
|
|
title: form.title.trim(),
|
|
slug: form.slug.trim() || null,
|
|
excerpt: form.excerpt.trim() || null,
|
|
body: form.body || null,
|
|
image_url: form.image_url || null,
|
|
published: form.published,
|
|
}
|
|
try {
|
|
if (isEdit) await api.admin.updatePost(post.id, payload)
|
|
else await api.admin.createPost(payload)
|
|
onSaved()
|
|
} catch (err) {
|
|
setError(err.message || 'Could not save the post.')
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
async function remove() {
|
|
if (!confirm('Delete this post? This cannot be undone.')) return
|
|
setBusy(true)
|
|
try {
|
|
await api.admin.deletePost(post.id)
|
|
onSaved()
|
|
} catch (err) {
|
|
setError(err.message || 'Could not delete.')
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
title={isEdit ? 'Edit post' : 'New post'}
|
|
onClose={onClose}
|
|
width={640}
|
|
footer={
|
|
<>
|
|
{isEdit && (
|
|
<button onClick={remove} disabled={busy} className="sans" style={delStyle}>
|
|
Delete
|
|
</button>
|
|
)}
|
|
<button onClick={onClose} disabled={busy} className="pill">
|
|
Cancel
|
|
</button>
|
|
<button onClick={save} disabled={busy || uploading} className="btn btn-primary btn-sq">
|
|
{busy ? 'Saving…' : 'Save'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
|
|
|
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
|
|
<label style={{ flex: '1 1 200px' }}>
|
|
<span className="field-label">Category</span>
|
|
<select value={form.category} onChange={set('category')} className="select">
|
|
{CATEGORIES.map((c) => (
|
|
<option key={c.v} value={c.v}>
|
|
{c.l}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label style={{ display: 'flex', alignItems: 'flex-end', gap: 8, paddingBottom: 11 }}>
|
|
<input type="checkbox" checked={form.published} onChange={set('published')} />
|
|
<span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>Published</span>
|
|
</label>
|
|
</div>
|
|
|
|
<label>
|
|
<span className="field-label">Title</span>
|
|
<input type="text" value={form.title} onChange={set('title')} className="input" />
|
|
</label>
|
|
|
|
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
|
|
<label style={{ flex: '1 1 200px' }}>
|
|
<span className="field-label">Slug (optional)</span>
|
|
<input type="text" value={form.slug} onChange={set('slug')} className="input" placeholder="auto" />
|
|
</label>
|
|
</div>
|
|
|
|
<label>
|
|
<span className="field-label">Excerpt (optional)</span>
|
|
<input type="text" value={form.excerpt} onChange={set('excerpt')} className="input" />
|
|
</label>
|
|
|
|
<label>
|
|
<span className="field-label">Image{isScreenshot ? ' (required)' : ' (optional)'}</span>
|
|
<input type="file" accept="image/*" onChange={onUpload} className="sans" style={{ color: 'var(--muted)', fontSize: '0.85rem' }} />
|
|
{uploading && <span className="sans dim" style={{ fontSize: '0.8rem' }}> uploading…</span>}
|
|
{form.image_url && (
|
|
<img src={form.image_url} alt="" style={{ display: 'block', marginTop: 10, maxWidth: '100%', borderRadius: 8, border: '1px solid var(--line)' }} />
|
|
)}
|
|
</label>
|
|
|
|
<div>
|
|
<span className="field-label">Body</span>
|
|
<Suspense fallback={<span className="spin" />}>
|
|
<RichTextEditor
|
|
value={form.body}
|
|
onChange={(html) => setForm((f) => ({ ...f, body: html }))}
|
|
variant={isScreenshot ? 'minimal' : 'post'}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
const delStyle = {
|
|
border: '1px solid #6e3b38',
|
|
borderRadius: 999,
|
|
padding: '7px 16px',
|
|
background: 'rgba(110,59,56,0.18)',
|
|
color: '#d98b84',
|
|
fontSize: '0.86rem',
|
|
cursor: 'pointer',
|
|
marginRight: 'auto',
|
|
}
|