Hero Phase 2: editor shell + background/overlay + draft/preview/publish
Second phase of the hero canvas editor (see HERO_EDITOR.md).
- new lib/heroLayout.js: shared defaultLayout/buildOverlay/heroBackground/
parseLayout used by both the portal and the editor (Portal refactored onto it)
- new admin view HeroEditor.jsx at /admin/hero (+ sidebar nav + route):
- live canvas preview (16:9) rendering the draft via HeroElement
- background panel: image upload (/admin/uploads, >1MB warning), 3x3 position
grid, overlay opacity slider — all update the canvas in real time
- debounced (800ms) auto-save to hero_layout_draft
- Publish (writes hero_layout + draft), Preview (opens /?preview=1), Revert
- Portal: ?preview=1 renders the draft via the admin settings endpoint, with a
"showing unpublished draft" banner; normal load renders the published layout
No schema/dep changes. Verified end to end: overlay/position update the canvas,
auto-save writes the draft, publish updates the live portal, preview shows the
draft while the public page shows live. Element drag/properties land in Phase 3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ import AdminLayout from './routes/admin/AdminLayout.jsx'
|
||||
import Dashboard from './routes/admin/views/Dashboard.jsx'
|
||||
import PostsAdmin from './routes/admin/views/PostsAdmin.jsx'
|
||||
import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
|
||||
import HeroEditor from './routes/admin/views/HeroEditor.jsx'
|
||||
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
||||
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
||||
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||
@@ -66,6 +67,7 @@ export default function App() {
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="posts" element={<PostsAdmin />} />
|
||||
<Route path="wiki" element={<WikiAdmin />} />
|
||||
<Route path="hero" element={<HeroEditor />} />
|
||||
<Route path="settings" element={<SettingsAdmin />} />
|
||||
<Route path="activity" element={<ActivityAdmin />} />
|
||||
<Route path="users" element={<UsersAdmin />} />
|
||||
|
||||
89
client/src/lib/heroLayout.js
Normal file
89
client/src/lib/heroLayout.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// Shared hero-layout helpers used by the public portal and the admin editor.
|
||||
|
||||
export const DEFAULT_HERO_IMAGE = '/assets/img/uomysticmoon-main-hero.png'
|
||||
|
||||
// The original hand-tuned multi-gradient hero background (used only for the
|
||||
// untouched default so the live page is byte-for-byte unchanged until edited).
|
||||
export const HERO_BG =
|
||||
"linear-gradient(90deg,rgba(11,15,20,0.34) 0%,rgba(11,15,20,0.5) 36%,rgba(11,15,20,0.78) 62%,rgba(11,15,20,0.66) 100%),linear-gradient(180deg,rgba(11,15,20,0.08) 0%,rgba(11,15,20,0.72) 100%),url('" +
|
||||
DEFAULT_HERO_IMAGE +
|
||||
"')"
|
||||
|
||||
// Single-stop dark overlay driven by the editor's opacity slider.
|
||||
export function buildOverlay(opacity) {
|
||||
return `linear-gradient(180deg,rgba(11,15,20,${opacity * 0.15}) 0%,rgba(11,15,20,${opacity}) 100%)`
|
||||
}
|
||||
|
||||
// Background style for a layout. When `isDefault` and no custom image is set, use
|
||||
// the exact original gradient stack; otherwise compose the overlay over the image.
|
||||
export function heroBackground(layout, { isDefault = false } = {}) {
|
||||
const bg = layout.background || {}
|
||||
const backgroundImage =
|
||||
isDefault && !bg.image_url
|
||||
? HERO_BG
|
||||
: `${buildOverlay(layout.overlay?.opacity ?? 0.72)}, url('${bg.image_url || DEFAULT_HERO_IMAGE}')`
|
||||
return {
|
||||
backgroundColor: 'var(--bg-deep)',
|
||||
backgroundImage,
|
||||
backgroundPosition: `${bg.position_x || 'left'} ${bg.position_y || 'center'}`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: bg.size || 'cover',
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a stored layout string; return null if missing/malformed/wrong version.
|
||||
export function parseLayout(str) {
|
||||
try {
|
||||
const l = str ? JSON.parse(str) : null
|
||||
return l && l.version === 1 && Array.isArray(l.elements) ? l : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// The current hardcoded hero as a HeroLayout, so the page is unchanged until
|
||||
// staff publish their own. Font sizes use the existing clamp() strings so the
|
||||
// default stays responsive (editor-created text uses px).
|
||||
export function defaultLayout(teaser) {
|
||||
return {
|
||||
version: 1,
|
||||
background: { image_url: null, position_x: 'left', position_y: 'center', size: 'cover' },
|
||||
overlay: { opacity: 0.72 },
|
||||
elements: [
|
||||
{
|
||||
id: 'default-text',
|
||||
type: 'text_block',
|
||||
x: 50,
|
||||
y: 42,
|
||||
z: 1,
|
||||
anchor: 'center',
|
||||
props: {
|
||||
align: 'center',
|
||||
width: 760,
|
||||
lines: [
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'default-buttons',
|
||||
type: 'buttons',
|
||||
x: 50,
|
||||
y: 72,
|
||||
z: 2,
|
||||
anchor: 'center',
|
||||
props: {
|
||||
align: 'center',
|
||||
gap: 12,
|
||||
items: [
|
||||
{ label: 'Enter the Website', to: '/site', variant: 'primary' },
|
||||
{ label: 'Open the Wiki', to: '/wiki', variant: 'ghost' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ const NAV = [
|
||||
{ to: '/admin', label: 'Dashboard', end: true },
|
||||
{ to: '/admin/posts', label: 'Posts' },
|
||||
{ to: '/admin/wiki', label: 'Wiki' },
|
||||
{ to: '/admin/hero', label: 'Hero Editor' },
|
||||
{ to: '/admin/settings', label: 'Settings' },
|
||||
{ to: '/admin/activity', label: 'Activity' },
|
||||
{ to: '/admin/users', label: 'Users' },
|
||||
@@ -17,6 +18,7 @@ const TITLES = {
|
||||
'/admin': 'Dashboard',
|
||||
'/admin/posts': 'Posts',
|
||||
'/admin/wiki': 'Wiki Pages',
|
||||
'/admin/hero': 'Hero Editor',
|
||||
'/admin/settings': 'Site Settings',
|
||||
'/admin/activity': 'Activity Log',
|
||||
'/admin/users': 'Users',
|
||||
|
||||
216
client/src/routes/admin/views/HeroEditor.jsx
Normal file
216
client/src/routes/admin/views/HeroEditor.jsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import HeroElement from '../../../components/HeroElement.jsx'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { defaultLayout, parseLayout, heroBackground } from '../../../lib/heroLayout.js'
|
||||
|
||||
const POS_Y = ['top', 'center', 'bottom']
|
||||
const POS_X = ['left', 'center', 'right']
|
||||
|
||||
export default function HeroEditor() {
|
||||
const [layout, setLayout] = useState(null) // working draft
|
||||
const [live, setLive] = useState(null) // last published (for revert)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const teaserRef = useRef('')
|
||||
const skipSave = useRef(true) // don't autosave right after load / revert
|
||||
|
||||
// Load draft → live → default.
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api.admin
|
||||
.getSettings()
|
||||
.then((s) => {
|
||||
if (!active) return
|
||||
teaserRef.current = s.homepage_teaser || ''
|
||||
const liveL = parseLayout(s.hero_layout)
|
||||
setLive(liveL)
|
||||
skipSave.current = true
|
||||
setLayout(parseLayout(s.hero_layout_draft) || liveL || defaultLayout(teaserRef.current))
|
||||
})
|
||||
.catch(() => active && setError('Could not load hero settings.'))
|
||||
.finally(() => active && setLoading(false))
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Debounced auto-save to the draft key.
|
||||
useEffect(() => {
|
||||
if (!layout) return
|
||||
if (skipSave.current) {
|
||||
skipSave.current = false
|
||||
return
|
||||
}
|
||||
setStatus('Saving…')
|
||||
const t = setTimeout(() => {
|
||||
api.admin
|
||||
.updateSettings({ hero_layout_draft: JSON.stringify(layout) })
|
||||
.then(() => setStatus('Draft saved'))
|
||||
.catch(() => setStatus('Save failed'))
|
||||
}, 800)
|
||||
return () => clearTimeout(t)
|
||||
}, [layout])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!layout) return null
|
||||
|
||||
const bg = layout.background || {}
|
||||
const overlay = layout.overlay?.opacity ?? 0.72
|
||||
const elements = [...layout.elements].sort((a, b) => (a.z || 0) - (b.z || 0))
|
||||
|
||||
const patchBg = (patch) => setLayout((l) => ({ ...l, background: { ...l.background, ...patch } }))
|
||||
const setOpacity = (opacity) => setLayout((l) => ({ ...l, overlay: { ...l.overlay, opacity } }))
|
||||
|
||||
async function onUploadBg(e) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
if (file.size > 1024 * 1024 && !confirm('This image is over 1 MB and may slow the page. Upload anyway?')) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const { url } = await api.admin.upload(file)
|
||||
patchBg({ image_url: url })
|
||||
} catch (err) {
|
||||
setStatus(err.message || 'Upload failed')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function preview() {
|
||||
// Force-save the draft so the new tab shows the latest.
|
||||
await api.admin.updateSettings({ hero_layout_draft: JSON.stringify(layout) }).catch(() => {})
|
||||
window.open('/?preview=1', '_blank', 'noopener')
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
const json = JSON.stringify(layout)
|
||||
try {
|
||||
await api.admin.updateSettings({ hero_layout: json, hero_layout_draft: json })
|
||||
setLive(layout)
|
||||
setStatus('Published ✓')
|
||||
} catch (err) {
|
||||
setStatus(err.message || 'Publish failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function revert() {
|
||||
if (!confirm('Discard draft changes and revert to the live hero?')) return
|
||||
const base = live || defaultLayout(teaserRef.current)
|
||||
skipSave.current = true
|
||||
setLayout(base)
|
||||
await api.admin.updateSettings({ hero_layout_draft: live ? JSON.stringify(live) : '' }).catch(() => {})
|
||||
setStatus('Reverted to live')
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: 18 }}>
|
||||
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||
Compose the portal hero. {status && <span style={{ color: 'var(--accent)' }}>· {status}</span>}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={revert} className="pill">
|
||||
Revert to live
|
||||
</button>
|
||||
<button onClick={preview} className="pill">
|
||||
Preview ↗
|
||||
</button>
|
||||
<button onClick={publish} className="btn btn-primary btn-sq">
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
{/* Canvas */}
|
||||
<div style={{ flex: '1 1 460px', minWidth: 320 }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: '16 / 9',
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid var(--line)',
|
||||
...heroBackground(layout),
|
||||
}}
|
||||
>
|
||||
{elements.map((el) => (
|
||||
<HeroElement key={el.id} element={el} />
|
||||
))}
|
||||
</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 8 }}>
|
||||
Live preview of the draft. Element drag & properties arrive in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Background / overlay panel */}
|
||||
<aside className="panel-flat" style={{ flex: '0 0 290px', padding: 18, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div>
|
||||
<span className="field-label">Background image</span>
|
||||
{bg.image_url ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<img src={bg.image_url} alt="" style={{ width: 70, height: 44, objectFit: 'cover', borderRadius: 6, border: '1px solid var(--line)' }} />
|
||||
<button onClick={() => patchBg({ image_url: null })} className="pill" style={{ fontSize: '0.8rem' }}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="sans dim" style={{ margin: '0 0 8px', fontSize: '0.8rem' }}>
|
||||
Using the default hero image.
|
||||
</p>
|
||||
)}
|
||||
<label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', marginTop: 10, cursor: 'pointer' }}>
|
||||
{uploading ? 'Uploading…' : 'Upload image'}
|
||||
<input type="file" accept="image/*" onChange={onUploadBg} hidden disabled={uploading} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="field-label">Background position</span>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, maxWidth: 132 }}>
|
||||
{POS_Y.map((py) =>
|
||||
POS_X.map((px) => {
|
||||
const activePos = (bg.position_x || 'left') === px && (bg.position_y || 'center') === py
|
||||
return (
|
||||
<button
|
||||
key={`${px}-${py}`}
|
||||
title={`${py} ${px}`}
|
||||
onClick={() => patchBg({ position_x: px, position_y: py })}
|
||||
style={{
|
||||
height: 36,
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
border: `1px solid ${activePos ? 'var(--accent)' : 'var(--line)'}`,
|
||||
background: activePos ? 'var(--blue)' : 'transparent',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="field-label">Overlay darkness — {Math.round(overlay * 100)}%</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={overlay}
|
||||
onChange={(e) => setOpacity(Number(e.target.value))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,66 +1,10 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import HeroElement from '../../components/HeroElement.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
const DEFAULT_HERO_IMAGE = '/assets/img/uomysticmoon-main-hero.png'
|
||||
const HERO_BG =
|
||||
"linear-gradient(90deg,rgba(11,15,20,0.34) 0%,rgba(11,15,20,0.5) 36%,rgba(11,15,20,0.78) 62%,rgba(11,15,20,0.66) 100%),linear-gradient(180deg,rgba(11,15,20,0.08) 0%,rgba(11,15,20,0.72) 100%),url('" +
|
||||
DEFAULT_HERO_IMAGE +
|
||||
"')"
|
||||
|
||||
// Single-stop dark overlay driven by the editor's opacity slider.
|
||||
function buildOverlay(opacity) {
|
||||
return `linear-gradient(180deg,rgba(11,15,20,${opacity * 0.15}) 0%,rgba(11,15,20,${opacity}) 100%)`
|
||||
}
|
||||
|
||||
// The current hardcoded hero, expressed as a HeroLayout so the page is unchanged
|
||||
// until staff publish their own. Font sizes use the existing clamp() strings so
|
||||
// the default stays responsive (editor-created text uses px).
|
||||
function defaultLayout(teaser) {
|
||||
return {
|
||||
version: 1,
|
||||
background: { image_url: null, position_x: 'left', position_y: 'center', size: 'cover' },
|
||||
overlay: { opacity: 0.72 },
|
||||
elements: [
|
||||
{
|
||||
id: 'default-text',
|
||||
type: 'text_block',
|
||||
x: 50,
|
||||
y: 42,
|
||||
z: 1,
|
||||
anchor: 'center',
|
||||
props: {
|
||||
align: 'center',
|
||||
width: 760,
|
||||
lines: [
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'default-buttons',
|
||||
type: 'buttons',
|
||||
x: 50,
|
||||
y: 72,
|
||||
z: 2,
|
||||
anchor: 'center',
|
||||
props: {
|
||||
align: 'center',
|
||||
gap: 12,
|
||||
items: [
|
||||
{ label: 'Enter the Website', to: '/site', variant: 'primary' },
|
||||
{ label: 'Open the Wiki', to: '/wiki', variant: 'ghost' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
import { api } from '../../api/client.js'
|
||||
import { defaultLayout, parseLayout, heroBackground } from '../../lib/heroLayout.js'
|
||||
|
||||
const QUICK = [
|
||||
{ label: 'News', to: '/site/news' },
|
||||
@@ -85,47 +29,57 @@ const DESTINATIONS = [
|
||||
},
|
||||
]
|
||||
|
||||
// Admin "Preview" opens the portal with ?preview=1 to render the unpublished draft.
|
||||
const PREVIEW = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('preview') === '1'
|
||||
|
||||
export default function Portal() {
|
||||
const { settings } = useSite()
|
||||
const teaser =
|
||||
settings.homepage_teaser ||
|
||||
'Mysticmoon is still being shaped beneath a midnight sky — a quiet preview for the news, screenshots, guides, and community notes to come as the world wakes.'
|
||||
|
||||
// Parse the published layout; fall back to the pre-populated default if missing,
|
||||
// Published layout (public). Falls back to the pre-populated default if missing,
|
||||
// malformed, the wrong version, or empty — so the hero is never blank.
|
||||
const parsed = useMemo(() => {
|
||||
try {
|
||||
const l = settings.hero_layout ? JSON.parse(settings.hero_layout) : null
|
||||
return l && l.version === 1 && Array.isArray(l.elements) && l.elements.length ? l : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [settings.hero_layout])
|
||||
const published = useMemo(() => parseLayout(settings.hero_layout), [settings.hero_layout])
|
||||
|
||||
const layout = parsed || defaultLayout(teaser)
|
||||
const isDefault = !parsed
|
||||
const bg = layout.background || {}
|
||||
// Keep the exact multi-gradient look for the untouched default; otherwise build
|
||||
// from the editor's overlay opacity over the chosen (or default) image.
|
||||
const backgroundImage =
|
||||
isDefault && !bg.image_url
|
||||
? HERO_BG
|
||||
: `${buildOverlay(layout.overlay?.opacity ?? 0.72)}, url('${bg.image_url || DEFAULT_HERO_IMAGE}')`
|
||||
// In preview mode, pull the draft via the admin endpoint (requires a logged-in
|
||||
// admin cookie); falls back silently to the published/default layout otherwise.
|
||||
const [draft, setDraft] = useState(null)
|
||||
useEffect(() => {
|
||||
if (!PREVIEW) return
|
||||
let active = true
|
||||
api.admin
|
||||
.getSettings()
|
||||
.then((s) => active && setDraft(parseLayout(s.hero_layout_draft)))
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const active = (PREVIEW && draft) || (published && published.elements.length ? published : null)
|
||||
const layout = active || defaultLayout(teaser)
|
||||
const isDefault = !active
|
||||
const bgStyle = heroBackground(layout, { isDefault })
|
||||
const elements = [...layout.elements].sort((a, b) => (a.z || 0) - (b.z || 0))
|
||||
|
||||
return (
|
||||
<PublicLayout header={false}>
|
||||
<main style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{PREVIEW && draft && (
|
||||
<div
|
||||
className="sans"
|
||||
style={{ background: 'var(--accent)', color: 'var(--bg-deep)', textAlign: 'center', padding: '6px 12px', fontSize: '0.8rem', fontWeight: 700, letterSpacing: '0.04em' }}
|
||||
>
|
||||
Preview — showing unpublished draft
|
||||
</div>
|
||||
)}
|
||||
<section
|
||||
style={{
|
||||
position: 'relative',
|
||||
minHeight: 'clamp(600px,72vh,860px)',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'var(--bg-deep)',
|
||||
backgroundImage,
|
||||
backgroundPosition: `${bg.position_x || 'left'} ${bg.position_y || 'center'}`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: bg.size || 'cover',
|
||||
...bgStyle,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: 0, padding: '0 max(18px,calc((100% - 1080px)/2))' }}>
|
||||
|
||||
Reference in New Issue
Block a user