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) => (
-