diff --git a/client/src/components/HeroElement.jsx b/client/src/components/HeroElement.jsx index fd3efb0..75583f7 100644 --- a/client/src/components/HeroElement.jsx +++ b/client/src/components/HeroElement.jsx @@ -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 (
{(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 ( + + ) + } return ( {line.text} diff --git a/client/src/lib/heroLayout.js b/client/src/lib/heroLayout.js index 94aafec..5cc7a2d 100644 --- a/client/src/lib/heroLayout.js +++ b/client/src/lib/heroLayout.js @@ -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 }, ], }, }, diff --git a/client/src/routes/admin/views/SettingsAdmin.jsx b/client/src/routes/admin/views/SettingsAdmin.jsx index 0cbd339..95d1eaa 100644 --- a/client/src/routes/admin/views/SettingsAdmin.jsx +++ b/client/src/routes/admin/views/SettingsAdmin.jsx @@ -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 if (error) return - 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 (
- {FIELDS.map((f) => ( -