Give the homepage teaser a rich text editor
Replace the plain textarea for the homepage_teaser setting with the shared TipTap rich-text editor, and render the teaser as sanitized HTML in the portal hero's default layout. - SettingsAdmin: teaser field now uses RichTextEditor (lazy-loaded, code-split like PostEditor); rich fields render in a <div> wrapper instead of <label>. - HeroElement: text-block lines flagged `html` render sanitized HTML. - heroLayout: the default-layout teaser line is now an HTML line. - admin.controller: sanitize homepage_teaser against the body allowlist on save. - theme.css: collapse the teaser's nested block margins in the hero. Closes #48 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
const MOON_IMAGE = '/assets/img/hero-moon.png'
|
||||
|
||||
@@ -34,7 +35,19 @@ function TextBlock({ props }) {
|
||||
return (
|
||||
<div style={{ textAlign: align, textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
|
||||
{(props.lines || []).map((line, i) => {
|
||||
const Tag = /^(h1|h2|h3|p|span)$/.test(line.tag) ? line.tag : 'p'
|
||||
const Tag = /^(h1|h2|h3|p|span|div)$/.test(line.tag) ? line.tag : 'p'
|
||||
// A rich-text line (e.g. the homepage teaser) carries sanitized HTML;
|
||||
// sanitize again on render as defense in depth. Others render as text.
|
||||
if (line.html) {
|
||||
return (
|
||||
<Tag
|
||||
key={i}
|
||||
className="hero-rich"
|
||||
style={lineStyle(line)}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(line.text || '') }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Tag key={i} style={lineStyle(line)}>
|
||||
{line.text}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function defaultLayout(teaser) {
|
||||
{ text: 'Private shard project', tag: 'span', fontSize: '0.74rem', color: '#c2d2e6', weight: 700, letterSpacing: '0.22em', transform: 'uppercase', font: 'sans' },
|
||||
{ text: 'UOMysticmoon', tag: 'h1', fontSize: 'clamp(3rem,8.5vw,5.75rem)', color: 'var(--head)', weight: 600, letterSpacing: '0.02em', lineHeight: 1, font: 'display', marginTop: 14 },
|
||||
{ text: 'A private Ultima Online world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 },
|
||||
{ text: teaser, tag: 'p', fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 },
|
||||
{ text: teaser, tag: 'div', html: true, fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { lazy, Suspense, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
import EmailDelivery from './EmailDelivery.jsx'
|
||||
|
||||
// Lazy-loaded so the heavy rich-text editor stays code-split (matches PostEditor).
|
||||
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
|
||||
|
||||
// Editable settings shown on this screen (key -> label + control type).
|
||||
const FIELDS = [
|
||||
{ key: 'site_title', label: 'Site title' },
|
||||
{ key: 'homepage_teaser', label: 'Homepage teaser', long: true },
|
||||
{
|
||||
key: 'homepage_teaser',
|
||||
label: 'Homepage teaser',
|
||||
rich: true,
|
||||
help: 'Rich text shown under the hero heading on the portal (when no custom hero layout is published).',
|
||||
},
|
||||
{ key: 'maintenance_message', label: 'Maintenance message', long: true },
|
||||
{ key: 'status_message', label: 'Status message' },
|
||||
{
|
||||
@@ -59,10 +67,13 @@ export default function SettingsAdmin() {
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
const set = (k) => (e) => {
|
||||
setValues((v) => ({ ...v, [k]: e.target.value }))
|
||||
// setRaw takes the next value directly (rich editor onChange), set adapts a
|
||||
// DOM change event onto it.
|
||||
const setRaw = (k) => (val) => {
|
||||
setValues((v) => ({ ...v, [k]: val }))
|
||||
setSaved(false)
|
||||
}
|
||||
const set = (k) => (e) => setRaw(k)(e.target.value)
|
||||
|
||||
async function save() {
|
||||
setBusy(true)
|
||||
@@ -82,10 +93,18 @@ export default function SettingsAdmin() {
|
||||
return (
|
||||
<section style={{ maxWidth: 620 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
{FIELDS.map((f) => (
|
||||
<label key={f.key} style={{ display: 'block' }}>
|
||||
{FIELDS.map((f) => {
|
||||
// A rich field can't live inside a <label> (nested toolbar buttons +
|
||||
// contenteditable), so it uses a plain <div> wrapper instead.
|
||||
const Wrap = f.rich ? 'div' : 'label'
|
||||
return (
|
||||
<Wrap key={f.key} style={{ display: 'block' }}>
|
||||
<span className="field-label">{f.label}</span>
|
||||
{f.options ? (
|
||||
{f.rich ? (
|
||||
<Suspense fallback={<span className="spin" />}>
|
||||
<RichTextEditor value={values[f.key]} onChange={setRaw(f.key)} variant="post" />
|
||||
</Suspense>
|
||||
) : f.options ? (
|
||||
<select value={values[f.key]} onChange={set(f.key)} className="select">
|
||||
{f.options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
@@ -103,8 +122,9 @@ export default function SettingsAdmin() {
|
||||
{f.help}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</Wrap>
|
||||
)
|
||||
})}
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 6, alignItems: 'center' }}>
|
||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Save changes'}
|
||||
|
||||
@@ -546,6 +546,20 @@ button[disabled] {
|
||||
}
|
||||
|
||||
/* ===== Hero canvas editor ===== */
|
||||
/* Rich-text hero line (e.g. the homepage teaser). Inherits the line's font/color
|
||||
from its inline style; collapse the editor's outer block margins so spacing is
|
||||
driven by the line's own marginTop rather than a nested <p>. */
|
||||
.hero-rich > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.hero-rich > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.hero-rich a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.hero-el-editable {
|
||||
outline: 1px dashed rgba(127, 153, 189, 0.45);
|
||||
outline-offset: 2px;
|
||||
|
||||
Reference in New Issue
Block a user