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,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'}
|
||||
|
||||
Reference in New Issue
Block a user