RTE Posts upgrade: TipTap rich-text editing + sanitization for posts #7
@@ -25,7 +25,13 @@ function escapeHtml(s) {
|
|||||||
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c])
|
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 fileRef = useRef(null)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [linkMenu, setLinkMenu] = useState(false)
|
const [linkMenu, setLinkMenu] = useState(false)
|
||||||
@@ -90,45 +96,51 @@ export default function RichTextEditor({ value, onChange, pages = [] }) {
|
|||||||
return (
|
return (
|
||||||
<div className="rte">
|
<div className="rte">
|
||||||
<div className="rte-toolbar">
|
<div className="rte-toolbar">
|
||||||
<Btn title="Bold" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
{showText && (
|
||||||
<b>B</b>
|
<>
|
||||||
</Btn>
|
<Btn title="Bold" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||||
<Btn title="Italic" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
|
<b>B</b>
|
||||||
<i>I</i>
|
</Btn>
|
||||||
</Btn>
|
<Btn title="Italic" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||||||
<Btn title="Strikethrough" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
|
<i>I</i>
|
||||||
<s>S</s>
|
</Btn>
|
||||||
</Btn>
|
<Btn title="Strikethrough" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
|
||||||
<span className="rte-sep" />
|
<s>S</s>
|
||||||
<Btn title="Heading 2 (table of contents)" active={editor.isActive('heading', { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
|
</Btn>
|
||||||
H2
|
<span className="rte-sep" />
|
||||||
</Btn>
|
<Btn title="Heading 2 (table of contents)" active={editor.isActive('heading', { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
|
||||||
<Btn title="Heading 3" active={editor.isActive('heading', { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
|
H2
|
||||||
H3
|
</Btn>
|
||||||
</Btn>
|
<Btn title="Heading 3" active={editor.isActive('heading', { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
|
||||||
<span className="rte-sep" />
|
H3
|
||||||
<Btn title="Bullet list" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
</Btn>
|
||||||
• List
|
<span className="rte-sep" />
|
||||||
</Btn>
|
<Btn title="Bullet list" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
||||||
<Btn title="Numbered list" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
• List
|
||||||
1. List
|
</Btn>
|
||||||
</Btn>
|
<Btn title="Numbered list" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
||||||
<Btn title="Quote" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
|
1. List
|
||||||
❝
|
</Btn>
|
||||||
</Btn>
|
<Btn title="Quote" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
|
||||||
<Btn title="Code block" active={editor.isActive('codeBlock')} onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
|
❝
|
||||||
{'</>'}
|
</Btn>
|
||||||
</Btn>
|
<Btn title="Code block" active={editor.isActive('codeBlock')} onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
|
||||||
<Btn title="Divider" onClick={() => editor.chain().focus().setHorizontalRule().run()}>
|
{'</>'}
|
||||||
—
|
</Btn>
|
||||||
</Btn>
|
<Btn title="Divider" onClick={() => editor.chain().focus().setHorizontalRule().run()}>
|
||||||
<span className="rte-sep" />
|
—
|
||||||
<Btn title="Link" active={editor.isActive('link')} onClick={setLink}>
|
</Btn>
|
||||||
🔗
|
<span className="rte-sep" />
|
||||||
</Btn>
|
<Btn title="Link" active={editor.isActive('link')} onClick={setLink}>
|
||||||
<Btn title="Link to another wiki page" disabled={pages.length === 0} onClick={() => setLinkMenu((v) => !v)}>
|
🔗
|
||||||
📄
|
</Btn>
|
||||||
</Btn>
|
{showWikiLink && (
|
||||||
|
<Btn title="Link to another wiki page" onClick={() => setLinkMenu((v) => !v)}>
|
||||||
|
📄
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Btn title="Insert image" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
<Btn title="Insert image" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
||||||
{uploading ? '…' : '🖼'}
|
{uploading ? '…' : '🖼'}
|
||||||
</Btn>
|
</Btn>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { lazy, Suspense, useState } from 'react'
|
||||||
import Modal from '../../../components/Modal.jsx'
|
import Modal from '../../../components/Modal.jsx'
|
||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
{ v: 'news', l: 'News' },
|
{ v: 'news', l: 'News' },
|
||||||
{ v: 'five-on-friday', l: 'Five on Friday' },
|
{ v: 'five-on-friday', l: 'Five on Friday' },
|
||||||
@@ -146,10 +148,16 @@ export default function PostEditor({ post, onClose, onSaved }) {
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<div>
|
||||||
<span className="field-label">Body (HTML or text)</span>
|
<span className="field-label">Body</span>
|
||||||
<textarea value={form.body} onChange={set('body')} className="textarea" />
|
<Suspense fallback={<span className="spin" />}>
|
||||||
</label>
|
<RichTextEditor
|
||||||
|
value={form.body}
|
||||||
|
onChange={(html) => setForm((f) => ({ ...f, body: html }))}
|
||||||
|
variant={isScreenshot ? 'minimal' : 'post'}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import DOMPurify from 'dompurify'
|
||||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
import PageHeader from '../../components/PageHeader.jsx'
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||||
@@ -36,7 +37,7 @@ export default function FiveOnFriday() {
|
|||||||
{it.title}
|
{it.title}
|
||||||
</h2>
|
</h2>
|
||||||
{it.body ? (
|
{it.body ? (
|
||||||
<div className="prose" dangerouslySetInnerHTML={{ __html: it.body }} />
|
<div className="prose" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(it.body) }} />
|
||||||
) : (
|
) : (
|
||||||
it.excerpt && <p style={{ margin: 0, color: 'var(--text)' }}>{it.excerpt}</p>
|
it.excerpt && <p style={{ margin: 0, color: 'var(--text)' }}>{it.excerpt}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
import { useAsync } from '../../lib/useAsync.js'
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
@@ -52,7 +53,7 @@ function Issue({ issue }) {
|
|||||||
{issue.excerpt && <p style={{ margin: '14px 0 0', color: 'var(--muted)', fontSize: '1.1rem' }}>{issue.excerpt}</p>}
|
{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' }} />
|
<div style={{ height: 1, background: 'var(--line)', margin: '28px 0' }} />
|
||||||
{issue.body ? (
|
{issue.body ? (
|
||||||
<div className="prose" dangerouslySetInnerHTML={{ __html: issue.body }} />
|
<div className="prose" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(issue.body) }} />
|
||||||
) : (
|
) : (
|
||||||
<p className="muted">This issue has no content yet.</p>
|
<p className="muted">This issue has no content yet.</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
const postsDb = require('./posts.db')
|
const postsDb = require('./posts.db')
|
||||||
|
const { cleanBody, deriveExcerpt } = require('../../utils/sanitizeHtml')
|
||||||
|
|
||||||
|
// Sanitize body HTML and treat an empty editor (TipTap emits `<p></p>`) 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() === '<p></p>' ? null : clean
|
||||||
|
}
|
||||||
|
|
||||||
// URL category (kebab) <-> DB enum value.
|
// URL category (kebab) <-> DB enum value.
|
||||||
const CATEGORY_MAP = {
|
const CATEGORY_MAP = {
|
||||||
@@ -40,12 +49,22 @@ async function getById(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function create(post) {
|
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)
|
return postsDb.findById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function update(id, fields) {
|
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)
|
return postsDb.findById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,4 +42,22 @@ function cleanBody(html) {
|
|||||||
return sanitizeHtml(String(html), OPTIONS)
|
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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user