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:
2026-07-11 11:06:58 -05:00
parent 5ccb18e794
commit a2590812e0
5 changed files with 65 additions and 11 deletions

View File

@@ -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'}