hero-feature #4
134
HERO_EDITOR.md
Normal file
134
HERO_EDITOR.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# UOMysticmoon — Hero Canvas Editor Spec
|
||||
|
||||
> Branch: **`hero-feature`**. Build contract for the WYSIWYG portal-hero editor.
|
||||
> Derived from the design doc *Hero Canvas Editor — Design Document*, **corrected
|
||||
> to match the current codebase** and with the open questions resolved.
|
||||
> Same workflow as the wiki upgrade: design → phased build → verify.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Let staff compose the portal hero (background image, overlay opacity, and floating
|
||||
elements — text, CTA buttons, moon, badge, image) in-browser, then preview and
|
||||
publish — no source edits. Layout persists as JSON in the existing `settings` table.
|
||||
|
||||
## 2. Locked decisions
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Scope | **Full v1** — background/overlay, all element types, drag/resize/z-order, draft→preview→publish (built in phases) |
|
||||
| CTA buttons | **First-class `buttons` element type** (independently positioned), not baked into a text block |
|
||||
| First run | **Pre-populate** the canvas with today's hero (headline, subtitle, teaser, CTAs) as editable elements so nothing changes visually until edited |
|
||||
| Drag | **Native Pointer Events** (mouse/touch/pen), zero dependencies |
|
||||
| Font size | Stored in **px** (fixed reference canvas) |
|
||||
| Image compression | **None** server-side; client warns when a file is > ~1 MB |
|
||||
| Preview | `?preview=1` renders the **draft** by reading it through the authenticated admin settings endpoint |
|
||||
| Other pages | Out of scope for v1 (design allows a per-page key later) |
|
||||
|
||||
## 3. Corrections to the design doc (current-code reality)
|
||||
|
||||
1. **Public settings is a whitelist, not `getAll()`.** `GET /api/v1/public/settings`
|
||||
→ `settings.getPublic()` → `PUBLIC_KEYS` in
|
||||
[settings.model.js](server/src/model/settings/settings.model.js). The doc's
|
||||
"no backend changes / picked up automatically" is wrong. **Fix:** add
|
||||
`hero_layout` to `PUBLIC_KEYS` (one line). `hero_layout_draft` stays out
|
||||
(admin-only) — which is why preview reads the draft via `api.admin.getSettings()`.
|
||||
2. **Moon is a reusable component** ([MoonDot.jsx](client/src/components/MoonDot.jsx),
|
||||
props `size`/`glow`), used in logo/login/maintenance — not "only the header."
|
||||
The `moon` element reuses it; it gains an optional `color`.
|
||||
3. **Route vs. nav live in different files.** `/admin/hero` route →
|
||||
[App.jsx](client/src/App.jsx); sidebar link/title → `NAV`/`TITLES` in
|
||||
[AdminLayout.jsx](client/src/routes/admin/AdminLayout.jsx).
|
||||
4. **Admin content area is `maxWidth: 1000px`** — the editor canvas renders
|
||||
scaled-to-fit; percentage positions stay faithful.
|
||||
|
||||
Everything else in the doc matches (hardcoded `HERO_BG` + CTAs + `homepage_teaser`
|
||||
in [Portal.jsx](client/src/routes/public/Portal.jsx); `updateSettings` accepts
|
||||
arbitrary keys; `/admin/uploads` exists; default hero asset present; TEXT settings
|
||||
columns — no schema change).
|
||||
|
||||
## 4. Data model — no schema change
|
||||
|
||||
Two `settings` keys (TEXT): `hero_layout` (live) and `hero_layout_draft` (admin).
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": 1,
|
||||
"background": { "image_url": null, "position_x": "left", "position_y": "center", "size": "cover" },
|
||||
"overlay": { "opacity": 0.72 },
|
||||
"elements": [
|
||||
{ "id": "uuid", "type": "text_block|buttons|moon|badge|image",
|
||||
"x": 50, "y": 42, "z": 1, "anchor": "center", "props": { /* per type */ } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Positions are **% of canvas** (reference width 1080, matching `.shell`), so the
|
||||
layout adapts across viewports without breakpoint data. `version` is validated
|
||||
(`=== 1`) before use; anything else falls back.
|
||||
|
||||
### Element props
|
||||
|
||||
| Type | Props |
|
||||
|---|---|
|
||||
| `text_block` | `lines: [{ text, tag(h1/h2/p/span), fontSize(px), color, weight }]`, `align` |
|
||||
| `buttons` | `items: [{ label, to, variant(primary/ghost) }]`, `align`, `gap` |
|
||||
| `moon` | `size`, `glow`, `color` |
|
||||
| `badge` | `text`, `bgColor`, `textColor`, `borderRadius` |
|
||||
| `image` | `src`, `width`(%), `alt` |
|
||||
|
||||
## 5. Backend changes
|
||||
- **One line:** add `'hero_layout'` to `PUBLIC_KEYS`. No new routes/controllers —
|
||||
layout saves through the existing `PUT /admin/settings`; images via `/admin/uploads`.
|
||||
|
||||
## 6. Frontend changes
|
||||
- **New** `client/src/components/HeroElement.jsx` — renders one element by type
|
||||
(shared by the live portal and the editor canvas).
|
||||
- **New** `client/src/routes/admin/views/HeroEditor.jsx` — canvas + element tray +
|
||||
properties panel; native-pointer drag/resize; background/overlay panel; snap grid;
|
||||
auto-save draft, preview, publish, revert.
|
||||
- **Edit** [Portal.jsx](client/src/routes/public/Portal.jsx) — parse `hero_layout`
|
||||
(or draft when `?preview=1` + admin), render elements, fall back to a
|
||||
`DEFAULT_LAYOUT` built from today's hero so the page is unchanged until edited.
|
||||
- **Edit** [AdminLayout.jsx](client/src/routes/admin/AdminLayout.jsx) (nav) +
|
||||
[App.jsx](client/src/App.jsx) (route `/admin/hero`).
|
||||
- **Edit** [MoonDot.jsx](client/src/components/MoonDot.jsx) — optional `color`.
|
||||
- **No** `client/src/api/client.js` changes needed beyond what exists
|
||||
(`admin.updateSettings`, `admin.getSettings`, `admin.upload`).
|
||||
|
||||
## 7. Phased build (each phase: build → verify in preview → commit)
|
||||
|
||||
- **Phase 0 — Spec** ✅ this document.
|
||||
- **Phase 1 — Data path & renderer** ✅ (verified 2026-06-28). `hero_layout`
|
||||
whitelisted; `HeroElement.jsx`; Portal renders the layout with a `DEFAULT_LAYOUT`
|
||||
fallback. Default render matches the old hero; publishing a layout re-renders;
|
||||
draft key not exposed publicly. Shared helpers moved to `client/src/lib/heroLayout.js`.
|
||||
- **Phase 2 — Editor shell + background/overlay** ✅ (verified 2026-06-28).
|
||||
`/admin/hero` view + sidebar nav; canvas live-preview; background upload + 3×3
|
||||
position + overlay opacity; debounced draft auto-save; publish; `?preview=1`
|
||||
reads the draft (admin) with a banner; revert. Verified: overlay/position update
|
||||
the canvas, auto-save writes the draft, publish writes live, preview shows the
|
||||
draft while the normal portal shows live.
|
||||
- **Phase 3 — Elements: select / drag / text_block / buttons** ✅ (verified
|
||||
2026-06-28). Element tray (+ Text / + Buttons); click-to-select with outline;
|
||||
native Pointer Events drag (% of canvas); Delete key + panel delete; z-order
|
||||
(send back / bring forward); text_block line editor (text/tag/size/color/bold,
|
||||
add/remove lines, align) and buttons editor (label/path/variant, add/remove).
|
||||
Verified: select shows the line editor, editing a line updates the canvas live,
|
||||
drag moved 50%→65%, add→3/delete→2 elements, empty-canvas click deselects.
|
||||
- **Phase 4 — moon + badge + image + resize + snap grid** ✅ (verified 2026-06-28).
|
||||
Tray adds moon/badge/image; property panels (moon: size/glow/color; badge:
|
||||
text/colors/radius; image: upload/width/alt); corner resize handle (image→width%,
|
||||
moon→size, text→box width); 8px snap-grid toggle with overlay; image placeholder
|
||||
until a file is chosen. Verified: each type adds + edits, resize moved a moon
|
||||
64→104px, snap grid shows, and a published moon+badge render on the live portal.
|
||||
|
||||
**Status: v1 feature-complete.** All phases verified end-to-end; ready for PR.
|
||||
Deferred (noted in the design doc as follow-ups): 8-point resize (only a corner
|
||||
handle for now), per-viewport layouts, server-side image compression.
|
||||
|
||||
## 8. Edge cases (from the doc, carried forward)
|
||||
- `JSON.parse` wrapped in try/catch + `version` check → fall back to `DEFAULT_LAYOUT`.
|
||||
- Element ids via `crypto.randomUUID()` (never array index).
|
||||
- Empty `elements` → render `DEFAULT_LAYOUT` so the hero is never blank.
|
||||
- Last-write-wins on concurrent admin edits (acceptable for this shard).
|
||||
- Client-side warning for background files > ~1 MB (no hard block; 8 MB server cap).
|
||||
BIN
client/public/assets/img/hero-moon.png
Normal file
BIN
client/public/assets/img/hero-moon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@@ -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 />} />
|
||||
|
||||
179
client/src/components/HeroElement.jsx
Normal file
179
client/src/components/HeroElement.jsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
const MOON_IMAGE = '/assets/img/hero-moon.png'
|
||||
|
||||
// Font family tokens a line may opt into; default is the page serif.
|
||||
const FONT = { display: 'var(--display)', sans: 'var(--sans)' }
|
||||
|
||||
// fontSize may be a number (px, from the editor) or a CSS string (e.g. a clamp()
|
||||
// used by the pre-populated default so the hero stays responsive until edited).
|
||||
function sizeToCss(v) {
|
||||
return typeof v === 'number' ? `${v}px` : v
|
||||
}
|
||||
|
||||
function lineStyle(line) {
|
||||
return {
|
||||
display: 'block', // each line stacks (so a span line behaves like the others)
|
||||
margin: line.marginTop != null ? `${line.marginTop}px 0 0` : '0',
|
||||
fontFamily: FONT[line.font] || undefined,
|
||||
fontSize: sizeToCss(line.fontSize),
|
||||
color: line.color || 'inherit',
|
||||
fontWeight: line.weight || undefined,
|
||||
fontStyle: line.italic ? 'italic' : undefined,
|
||||
letterSpacing: line.letterSpacing || undefined,
|
||||
textTransform: line.transform || undefined,
|
||||
lineHeight: line.lineHeight || undefined,
|
||||
maxWidth: line.maxWidth ? `${line.maxWidth}px` : undefined,
|
||||
marginLeft: line.maxWidth ? 'auto' : undefined,
|
||||
marginRight: line.maxWidth ? 'auto' : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function TextBlock({ props }) {
|
||||
const align = props.align || 'center'
|
||||
return (
|
||||
<div style={{ textAlign: align, textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
|
||||
{(props.lines || []).map((line, i) => {
|
||||
const Tag = /^(h1|h2|h3|p|span)$/.test(line.tag) ? line.tag : 'p'
|
||||
return (
|
||||
<Tag key={i} style={lineStyle(line)}>
|
||||
{line.text}
|
||||
</Tag>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Buttons({ props }) {
|
||||
const justify = props.align === 'left' ? 'flex-start' : props.align === 'right' ? 'flex-end' : 'center'
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: props.gap ?? 12, justifyContent: justify }}>
|
||||
{(props.items || []).map((b, i) => (
|
||||
<Link key={i} to={b.to || '#'} className={`btn ${b.variant === 'ghost' ? 'btn-ghost' : 'btn-primary'}`}>
|
||||
{b.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Badge({ props }) {
|
||||
return (
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '6px 14px',
|
||||
background: props.bgColor || 'rgba(11,22,48,0.6)',
|
||||
color: props.textColor || '#c2d2e6',
|
||||
borderRadius: props.borderRadius ?? 999,
|
||||
fontSize: '0.74rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.18em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function HeroImage({ props }) {
|
||||
if (!props.src) {
|
||||
// Editor placeholder until an image is chosen (a srcless image never ships live).
|
||||
return (
|
||||
<div
|
||||
className="sans"
|
||||
style={{ width: 160, height: 100, display: 'grid', placeItems: 'center', border: '1px dashed var(--accent)', borderRadius: 8, color: 'var(--muted)', fontSize: '0.8rem', background: 'rgba(11,22,48,0.4)' }}
|
||||
>
|
||||
Upload an image
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={props.src}
|
||||
alt={props.alt || ''}
|
||||
style={{ width: `${props.width || 40}%`, height: 'auto', display: 'block', borderRadius: 8 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function content(element) {
|
||||
switch (element.type) {
|
||||
case 'text_block':
|
||||
return <TextBlock props={element.props || {}} />
|
||||
case 'buttons':
|
||||
return <Buttons props={element.props || {}} />
|
||||
case 'moon': {
|
||||
const size = element.props?.size || 96
|
||||
const glow = element.props?.glow ?? 0.45
|
||||
return (
|
||||
<img
|
||||
src={MOON_IMAGE}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
width: size,
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
filter: glow ? `drop-shadow(0 0 ${size * 0.45}px rgba(216,226,239,${glow}))` : undefined,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'badge':
|
||||
return <Badge props={element.props || {}} />
|
||||
case 'image':
|
||||
return <HeroImage props={element.props || {}} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Absolute-positioned wrapper + type-specific content. In `editor` mode the inner
|
||||
// content is made non-interactive (so clicks select/drag the wrapper) and the
|
||||
// wrapper takes selection styling + an onPointerDown handler.
|
||||
export default function HeroElement({
|
||||
element,
|
||||
wrapperStyle,
|
||||
editor = false,
|
||||
selected = false,
|
||||
onPointerDown,
|
||||
children,
|
||||
}) {
|
||||
const anchor = element.anchor || 'center'
|
||||
const transform =
|
||||
anchor === 'center'
|
||||
? 'translate(-50%, -50%)'
|
||||
: anchor === 'top-right'
|
||||
? 'translateX(-100%)'
|
||||
: undefined
|
||||
// text_block/buttons may set a box width (px); kept within the containing block
|
||||
// (the hero section live, or the editor canvas) with small side gutters.
|
||||
const boxWidth =
|
||||
(element.type === 'text_block' || element.type === 'buttons') && element.props?.width
|
||||
? `min(${element.props.width}px, calc(100% - 36px))`
|
||||
: undefined
|
||||
const cls = [editor ? 'hero-el-editable' : '', selected ? 'is-selected' : ''].filter(Boolean).join(' ')
|
||||
return (
|
||||
<div
|
||||
className={cls || undefined}
|
||||
onPointerDown={onPointerDown}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${element.x}%`,
|
||||
top: `${element.y}%`,
|
||||
zIndex: element.z || 0,
|
||||
transform,
|
||||
width: boxWidth,
|
||||
cursor: editor ? 'move' : undefined,
|
||||
...wrapperStyle,
|
||||
}}
|
||||
>
|
||||
<div style={editor ? { pointerEvents: 'none' } : undefined}>{content(element)}</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
// The little glowing moon used in the logo, login, and maintenance screens.
|
||||
export default function MoonDot({ size = 13, glow = 0.45 }) {
|
||||
return (
|
||||
<span
|
||||
className="moon"
|
||||
style={{ width: size, height: size, boxShadow: `0 0 ${size * 0.8}px rgba(216,226,239,${glow})` }}
|
||||
/>
|
||||
)
|
||||
// The little glowing moon used in the logo, login, maintenance screens, and the
|
||||
// hero canvas. `color` overrides the radial-gradient start point (else the CSS
|
||||
// .moon default is used).
|
||||
export default function MoonDot({ size = 13, glow = 0.45, color }) {
|
||||
const style = { width: size, height: size, boxShadow: `0 0 ${size * 0.8}px rgba(216,226,239,${glow})` }
|
||||
if (color) style.background = `radial-gradient(circle at 35% 30%, ${color}, #9fb0c6 55%, #5d6e88)`
|
||||
return <span className="moon" style={style} />
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -39,6 +41,8 @@ export default function AdminLayout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const title = TITLES[location.pathname] || 'Admin'
|
||||
// The hero canvas editor needs room — let it use the full content width.
|
||||
const wide = location.pathname === '/admin/hero'
|
||||
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
||||
|
||||
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
||||
@@ -145,7 +149,7 @@ export default function AdminLayout() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: 1000, width: '100%' }}>
|
||||
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: wide ? 'none' : 1000, width: '100%' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
581
client/src/routes/admin/views/HeroEditor.jsx
Normal file
581
client/src/routes/admin/views/HeroEditor.jsx
Normal file
@@ -0,0 +1,581 @@
|
||||
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']
|
||||
|
||||
const clamp = (v, min, max) => Math.max(min, Math.min(max, v))
|
||||
const round2 = (v) => Math.round(v * 100) / 100
|
||||
const genId = () => (crypto.randomUUID ? crypto.randomUUID() : `el-${Date.now()}-${Math.random()}`)
|
||||
const hexOf = (v) => (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(v || '') ? v : '#ffffff')
|
||||
|
||||
function newElement(type, z) {
|
||||
const base = { id: genId(), type, x: 50, y: 50, z, anchor: 'center' }
|
||||
if (type === 'text_block') {
|
||||
return { ...base, props: { align: 'center', width: 600, lines: [{ text: 'New heading', tag: 'h2', fontSize: 36, color: '#ffffff', weight: 600 }] } }
|
||||
}
|
||||
if (type === 'buttons') {
|
||||
return { ...base, y: 60, props: { align: 'center', gap: 12, items: [{ label: 'Button', to: '/', variant: 'primary' }] } }
|
||||
}
|
||||
if (type === 'moon') return { ...base, props: { size: 96, glow: 0.5 } }
|
||||
if (type === 'badge') return { ...base, props: { text: 'New badge', bgColor: '#1a2d4a', textColor: '#c2d2e6', borderRadius: 999 } }
|
||||
if (type === 'image') return { ...base, props: { src: '', width: 40, alt: '' } }
|
||||
return base
|
||||
}
|
||||
|
||||
const RESIZABLE = { text_block: 'width', image: 'width', moon: 'size' }
|
||||
|
||||
export default function HeroEditor() {
|
||||
const [layout, setLayout] = useState(null)
|
||||
const [live, setLive] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [snap, setSnap] = useState(false)
|
||||
const [scale, setScale] = useState(1)
|
||||
const teaserRef = useRef('')
|
||||
const skipSave = useRef(true)
|
||||
const canvasRef = useRef(null) // the 1280x720 stage (scaled to fit)
|
||||
const colRef = useRef(null) // measures available width
|
||||
|
||||
// Render the canvas as a scaled 1280x720 stage so it's a faithful miniature of
|
||||
// the live hero (viewport-unit fonts + % positions all scale together).
|
||||
useEffect(() => {
|
||||
const el = colRef.current
|
||||
if (!el) return
|
||||
const recompute = () => setScale(Math.min(el.clientWidth / 1280, (window.innerHeight * 0.66) / 720))
|
||||
recompute()
|
||||
const ro = new ResizeObserver(recompute)
|
||||
ro.observe(el)
|
||||
window.addEventListener('resize', recompute)
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
window.removeEventListener('resize', recompute)
|
||||
}
|
||||
}, [loading])
|
||||
|
||||
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
|
||||
}
|
||||
}, [])
|
||||
|
||||
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])
|
||||
|
||||
// Delete key removes the selected element (unless typing in a field).
|
||||
useEffect(() => {
|
||||
if (!selectedId) return
|
||||
const onKey = (e) => {
|
||||
if (e.key !== 'Delete' && e.key !== 'Backspace') return
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return
|
||||
e.preventDefault()
|
||||
setLayout((l) => ({ ...l, elements: l.elements.filter((el) => el.id !== selectedId) }))
|
||||
setSelectedId(null)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [selectedId])
|
||||
|
||||
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 selected = layout.elements.find((e) => e.id === selectedId) || null
|
||||
|
||||
const patchBg = (patch) => setLayout((l) => ({ ...l, background: { ...l.background, ...patch } }))
|
||||
const setOpacity = (opacity) => setLayout((l) => ({ ...l, overlay: { ...l.overlay, opacity } }))
|
||||
const updateElement = (id, patch) =>
|
||||
setLayout((l) => ({ ...l, elements: l.elements.map((e) => (e.id === id ? { ...e, ...patch } : e)) }))
|
||||
const updateProps = (id, patch) =>
|
||||
setLayout((l) => ({ ...l, elements: l.elements.map((e) => (e.id === id ? { ...e, props: { ...e.props, ...patch } } : e)) }))
|
||||
const removeElement = (id) => {
|
||||
setLayout((l) => ({ ...l, elements: l.elements.filter((e) => e.id !== id) }))
|
||||
setSelectedId(null)
|
||||
}
|
||||
const bumpZ = (id, dir) =>
|
||||
setLayout((l) => ({ ...l, elements: l.elements.map((e) => (e.id === id ? { ...e, z: Math.max(0, (e.z || 0) + dir) } : e)) }))
|
||||
const addElement = (type) => {
|
||||
const z = Math.max(0, ...layout.elements.map((e) => e.z || 0)) + 1
|
||||
const el = newElement(type, z)
|
||||
setLayout((l) => ({ ...l, elements: [...l.elements, el] }))
|
||||
setSelectedId(el.id)
|
||||
}
|
||||
|
||||
function onElPointerDown(e, el) {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
setSelectedId(el.id)
|
||||
const rect = canvasRef.current.getBoundingClientRect()
|
||||
const sx = e.clientX
|
||||
const sy = e.clientY
|
||||
const ox = el.x
|
||||
const oy = el.y
|
||||
const node = e.currentTarget
|
||||
try {
|
||||
node.setPointerCapture(e.pointerId)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const stepX = (8 / 1280) * 100 // 8px snap on the 1280x720 stage, as %
|
||||
const stepY = (8 / 720) * 100
|
||||
const move = (ev) => {
|
||||
let nx = clamp(ox + ((ev.clientX - sx) / rect.width) * 100, 0, 100)
|
||||
let ny = clamp(oy + ((ev.clientY - sy) / rect.height) * 100, 0, 100)
|
||||
if (snap) {
|
||||
nx = Math.round(nx / stepX) * stepX
|
||||
ny = Math.round(ny / stepY) * stepY
|
||||
}
|
||||
updateElement(el.id, { x: round2(nx), y: round2(ny) })
|
||||
}
|
||||
const up = () => {
|
||||
node.removeEventListener('pointermove', move)
|
||||
node.removeEventListener('pointerup', up)
|
||||
}
|
||||
node.addEventListener('pointermove', move)
|
||||
node.addEventListener('pointerup', up)
|
||||
}
|
||||
|
||||
// Corner-handle resize: adjusts the type-appropriate dimension.
|
||||
function onResizePointerDown(e, el) {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
const dim = RESIZABLE[el.type]
|
||||
if (!dim) return
|
||||
const rect = canvasRef.current.getBoundingClientRect()
|
||||
const sx = e.clientX
|
||||
const orig = el.props?.[dim] ?? (dim === 'width' && el.type === 'image' ? 40 : dim === 'width' ? 600 : 64)
|
||||
const node = e.currentTarget
|
||||
try {
|
||||
node.setPointerCapture(e.pointerId)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const move = (ev) => {
|
||||
const dxPx = ev.clientX - sx
|
||||
const dxLogical = dxPx / scale // client px → stage px
|
||||
let val
|
||||
if (el.type === 'image') val = clamp(orig + (dxPx / rect.width) * 100, 5, 100) // %
|
||||
else if (el.type === 'moon') val = clamp(orig + dxLogical, 24, 400) // px
|
||||
else val = clamp(orig + dxLogical, 120, 1180) // text_block box px
|
||||
updateProps(el.id, { [dim]: Math.round(val) })
|
||||
}
|
||||
const up = () => {
|
||||
node.removeEventListener('pointermove', move)
|
||||
node.removeEventListener('pointerup', up)
|
||||
}
|
||||
node.addEventListener('pointermove', move)
|
||||
node.addEventListener('pointerup', up)
|
||||
}
|
||||
|
||||
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() {
|
||||
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
|
||||
skipSave.current = true
|
||||
setSelectedId(null)
|
||||
setLayout(live || defaultLayout(teaserRef.current))
|
||||
await api.admin.updateSettings({ hero_layout_draft: live ? JSON.stringify(live) : '' }).catch(() => {})
|
||||
setStatus('Reverted to live')
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: 16 }}>
|
||||
<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>
|
||||
|
||||
{/* Element tray */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span className="field-label" style={{ margin: 0 }}>Add:</span>
|
||||
<button onClick={() => addElement('text_block')} className="pill">+ Text</button>
|
||||
<button onClick={() => addElement('buttons')} className="pill">+ Buttons</button>
|
||||
<button onClick={() => addElement('moon')} className="pill">+ Moon</button>
|
||||
<button onClick={() => addElement('badge')} className="pill">+ Badge</button>
|
||||
<button onClick={() => addElement('image')} className="pill">+ Image</button>
|
||||
<button
|
||||
onClick={() => setSnap((v) => !v)}
|
||||
className="pill"
|
||||
style={{ marginLeft: 'auto', borderColor: snap ? 'var(--accent)' : 'var(--line)', color: snap ? 'var(--accent)' : 'var(--muted)' }}
|
||||
>
|
||||
Snap grid: {snap ? 'on' : 'off'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div ref={colRef} style={{ flex: '1 1 620px', minWidth: 320 }}>
|
||||
<div style={{ position: 'relative', width: 1280 * scale, height: 720 * scale, maxWidth: '100%', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--line)', background: 'var(--bg-deep)' }}>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
onPointerDown={(e) => {
|
||||
if (e.target === e.currentTarget) setSelectedId(null)
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
transformOrigin: 'top left',
|
||||
transform: `scale(${scale})`,
|
||||
...heroBackground(layout),
|
||||
}}
|
||||
>
|
||||
{snap && (
|
||||
<div className="hero-canvas-grid" style={{ position: 'absolute', inset: 0, backgroundSize: '16px 16px', pointerEvents: 'none', zIndex: 0 }} />
|
||||
)}
|
||||
{elements.map((el) => (
|
||||
<HeroElement
|
||||
key={el.id}
|
||||
element={el}
|
||||
editor
|
||||
selected={el.id === selectedId}
|
||||
onPointerDown={(e) => onElPointerDown(e, el)}
|
||||
>
|
||||
{el.id === selectedId && RESIZABLE[el.type] && (
|
||||
<div
|
||||
onPointerDown={(e) => onResizePointerDown(e, el)}
|
||||
title="Resize"
|
||||
style={{ position: 'absolute', right: -6, bottom: -6, width: 14, height: 14, borderRadius: 3, background: 'var(--accent)', border: '1px solid var(--bg-deep)', cursor: 'nwse-resize', pointerEvents: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
</HeroElement>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 8 }}>
|
||||
Click to select · drag to move · Delete key removes the selected element.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<aside className="panel-flat" style={{ flex: '0 0 320px', padding: 18, display: 'flex', flexDirection: 'column', gap: 16, position: 'sticky', top: 20 }}>
|
||||
{selected ? (
|
||||
<ElementPanel
|
||||
key={selected.id}
|
||||
element={selected}
|
||||
onProps={(patch) => updateProps(selected.id, patch)}
|
||||
onRemove={() => removeElement(selected.id)}
|
||||
onForward={() => bumpZ(selected.id, 1)}
|
||||
onBack={() => bumpZ(selected.id, -1)}
|
||||
onDeselect={() => setSelectedId(null)}
|
||||
/>
|
||||
) : (
|
||||
<BackgroundPanel bg={bg} overlay={overlay} uploading={uploading} onUpload={onUploadBg} patchBg={patchBg} setOpacity={setOpacity} />
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Background / overlay panel (no element selected) ────────────────────
|
||||
function BackgroundPanel({ bg, overlay, uploading, onUpload, patchBg, setOpacity }) {
|
||||
return (
|
||||
<>
|
||||
<p className="field-label" style={{ margin: 0 }}>Background & overlay</p>
|
||||
<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={onUpload} 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Per-element properties ──────────────────────────────────────────────
|
||||
function ElementPanel({ element, onProps, onRemove, onForward, onBack, onDeselect }) {
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<p className="field-label" style={{ margin: 0 }}>{element.type.replace('_', ' ')}</p>
|
||||
<span className="link-accent" style={{ fontSize: '0.8rem' }} onClick={onDeselect}>Done</span>
|
||||
</div>
|
||||
|
||||
{element.type === 'text_block' && <TextBlockPanel element={element} onProps={onProps} />}
|
||||
{element.type === 'buttons' && <ButtonsPanel element={element} onProps={onProps} />}
|
||||
{element.type === 'moon' && <MoonPanel element={element} onProps={onProps} />}
|
||||
{element.type === 'badge' && <BadgePanel element={element} onProps={onProps} />}
|
||||
{element.type === 'image' && <ImagePanel element={element} onProps={onProps} />}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, borderTop: '1px solid var(--line)', paddingTop: 12 }}>
|
||||
<button onClick={onBack} className="pill" style={{ fontSize: '0.8rem' }}>Send back</button>
|
||||
<button onClick={onForward} className="pill" style={{ fontSize: '0.8rem' }}>Bring forward</button>
|
||||
<button onClick={onRemove} className="pill" style={{ marginLeft: 'auto', fontSize: '0.8rem', color: '#d98b84', borderColor: '#6e3b38' }}>Delete</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ALIGNS = ['left', 'center', 'right']
|
||||
|
||||
function AlignField({ value, onChange }) {
|
||||
return (
|
||||
<label>
|
||||
<span className="field-label">Align</span>
|
||||
<select className="input" value={value || 'center'} onChange={(e) => onChange(e.target.value)}>
|
||||
{ALIGNS.map((a) => (
|
||||
<option key={a} value={a}>{a}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function TextBlockPanel({ element, onProps }) {
|
||||
const lines = element.props?.lines || []
|
||||
const setLine = (i, patch) => onProps({ lines: lines.map((l, idx) => (idx === i ? { ...l, ...patch } : l)) })
|
||||
const addLine = () => onProps({ lines: [...lines, { text: 'New line', tag: 'p', fontSize: 18, color: '#dbe2ea', weight: 400 }] })
|
||||
const removeLine = (i) => onProps({ lines: lines.filter((_, idx) => idx !== i) })
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<AlignField value={element.props?.align} onChange={(v) => onProps({ align: v })} />
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<input className="input" value={line.text} onChange={(e) => setLine(i, { text: e.target.value })} placeholder="Text" />
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<select className="input" value={line.tag || 'p'} onChange={(e) => setLine(i, { tag: e.target.value })} style={{ flex: 1 }}>
|
||||
{['h1', 'h2', 'h3', 'p', 'span'].map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={typeof line.fontSize === 'number' ? line.fontSize : ''}
|
||||
onChange={(e) => { const n = parseInt(e.target.value, 10); setLine(i, { fontSize: Number.isFinite(n) ? n : undefined }) }}
|
||||
placeholder="px"
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
<input type="color" value={hexOf(line.color)} onChange={(e) => setLine(i, { color: e.target.value })} style={{ width: 40, height: 38, padding: 2, border: '1px solid var(--line)', borderRadius: 6, background: 'var(--bg)' }} title="Color" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input type="checkbox" checked={(line.weight || 400) >= 700} onChange={(e) => setLine(i, { weight: e.target.checked ? 700 : 400 })} />
|
||||
<span className="sans" style={{ fontSize: '0.82rem', color: 'var(--muted)' }}>Bold</span>
|
||||
</label>
|
||||
{lines.length > 1 && (
|
||||
<span className="link-accent" style={{ fontSize: '0.8rem', color: '#d98b84' }} onClick={() => removeLine(i)}>Remove line</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addLine} className="pill" style={{ fontSize: '0.82rem', alignSelf: 'flex-start' }}>+ Add line</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonsPanel({ element, onProps }) {
|
||||
const items = element.props?.items || []
|
||||
const setItem = (i, patch) => onProps({ items: items.map((it, idx) => (idx === i ? { ...it, ...patch } : it)) })
|
||||
const addItem = () => onProps({ items: [...items, { label: 'Button', to: '/', variant: 'primary' }] })
|
||||
const removeItem = (i) => onProps({ items: items.filter((_, idx) => idx !== i) })
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<AlignField value={element.props?.align} onChange={(v) => onProps({ align: v })} />
|
||||
{items.map((it, i) => (
|
||||
<div key={i} style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<input className="input" value={it.label} onChange={(e) => setItem(i, { label: e.target.value })} placeholder="Label" />
|
||||
<input className="input" value={it.to} onChange={(e) => setItem(i, { to: e.target.value })} placeholder="/path" style={{ fontFamily: 'ui-monospace,Menlo,monospace' }} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<select className="input" value={it.variant || 'primary'} onChange={(e) => setItem(i, { variant: e.target.value })} style={{ flex: 1 }}>
|
||||
<option value="primary">primary</option>
|
||||
<option value="ghost">ghost</option>
|
||||
</select>
|
||||
{items.length > 1 && (
|
||||
<span className="link-accent" style={{ fontSize: '0.8rem', color: '#d98b84' }} onClick={() => removeItem(i)}>Remove</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addItem} className="pill" style={{ fontSize: '0.82rem', alignSelf: 'flex-start' }}>+ Add button</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const swatch = { width: '100%', height: 38, padding: 2, border: '1px solid var(--line)', borderRadius: 6, background: 'var(--bg)' }
|
||||
|
||||
function MoonPanel({ element, onProps }) {
|
||||
const p = element.props || {}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Uses the moon from the hero artwork.</p>
|
||||
<label>
|
||||
<span className="field-label">Size — {p.size || 96}px</span>
|
||||
<input type="range" min="24" max="320" step="1" value={p.size || 96} onChange={(e) => onProps({ size: Number(e.target.value) })} style={{ width: '100%' }} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Glow — {Math.round((p.glow ?? 0.5) * 100)}%</span>
|
||||
<input type="range" min="0" max="1" step="0.01" value={p.glow ?? 0.5} onChange={(e) => onProps({ glow: Number(e.target.value) })} style={{ width: '100%' }} />
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BadgePanel({ element, onProps }) {
|
||||
const p = element.props || {}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<label>
|
||||
<span className="field-label">Text</span>
|
||||
<input className="input" value={p.text || ''} onChange={(e) => onProps({ text: e.target.value })} />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<label style={{ flex: 1 }}>
|
||||
<span className="field-label">Background</span>
|
||||
<input type="color" value={hexOf(p.bgColor)} onChange={(e) => onProps({ bgColor: e.target.value })} style={swatch} />
|
||||
</label>
|
||||
<label style={{ flex: 1 }}>
|
||||
<span className="field-label">Text color</span>
|
||||
<input type="color" value={hexOf(p.textColor)} onChange={(e) => onProps({ textColor: e.target.value })} style={swatch} />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span className="field-label">Corner radius — {Math.min(p.borderRadius ?? 999, 24)}px</span>
|
||||
<input type="range" min="0" max="24" step="1" value={Math.min(p.borderRadius ?? 999, 24)} onChange={(e) => onProps({ borderRadius: Number(e.target.value) })} style={{ width: '100%' }} />
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImagePanel({ element, onProps }) {
|
||||
const p = element.props || {}
|
||||
const [up, setUp] = useState(false)
|
||||
async function onFile(e) {
|
||||
const f = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!f) return
|
||||
if (f.size > 1024 * 1024 && !confirm('This image is over 1 MB and may slow the page. Upload anyway?')) return
|
||||
setUp(true)
|
||||
try {
|
||||
const { url } = await api.admin.upload(f)
|
||||
onProps({ src: url })
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setUp(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<span className="field-label">Image</span>
|
||||
{p.src && <img src={p.src} alt="" style={{ width: '100%', maxHeight: 90, objectFit: 'contain', borderRadius: 6, border: '1px solid var(--line)', marginBottom: 8 }} />}
|
||||
<label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', cursor: 'pointer' }}>
|
||||
{up ? 'Uploading…' : p.src ? 'Replace' : 'Upload'}
|
||||
<input type="file" accept="image/*" onChange={onFile} hidden disabled={up} />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span className="field-label">Width — {p.width || 40}%</span>
|
||||
<input type="range" min="10" max="100" step="1" value={p.width || 40} onChange={(e) => onProps({ width: Number(e.target.value) })} style={{ width: '100%' }} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Alt text</span>
|
||||
<input className="input" value={p.alt || ''} onChange={(e) => onProps({ alt: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
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 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('/assets/img/uomysticmoon-main-hero.png')"
|
||||
import { api } from '../../api/client.js'
|
||||
import { defaultLayout, parseLayout, heroBackground } from '../../lib/heroLayout.js'
|
||||
|
||||
const QUICK = [
|
||||
{ label: 'News', to: '/site/news' },
|
||||
@@ -28,53 +29,63 @@ 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.'
|
||||
|
||||
// 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 published = useMemo(() => parseLayout(settings.hero_layout), [settings.hero_layout])
|
||||
|
||||
// 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',
|
||||
display: 'grid',
|
||||
alignContent: 'center',
|
||||
minHeight: 'clamp(600px,72vh,860px)',
|
||||
padding: '96px max(18px,calc((100% - 1080px)/2)) 96px',
|
||||
overflow: 'hidden',
|
||||
textAlign: 'center',
|
||||
backgroundColor: 'var(--bg-deep)',
|
||||
backgroundImage: HERO_BG,
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'cover',
|
||||
...bgStyle,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 760, margin: '0 auto', textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
|
||||
<p className="eyebrow" style={{ color: '#c2d2e6', letterSpacing: '0.22em' }}>
|
||||
Private shard project
|
||||
</p>
|
||||
<h1
|
||||
className="display"
|
||||
style={{ margin: 0, fontSize: 'clamp(3rem,8.5vw,5.75rem)', lineHeight: 1, letterSpacing: '0.02em' }}
|
||||
>
|
||||
UOMysticmoon
|
||||
</h1>
|
||||
<p style={{ margin: '22px auto 0', color: '#dbe2ea', fontSize: '1.32rem', fontStyle: 'italic' }}>
|
||||
A private Ultima Online world in progress
|
||||
</p>
|
||||
<p style={{ maxWidth: 600, margin: '22px auto 0', color: '#c4cdd8', fontSize: '1.06rem' }}>{teaser}</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'center', marginTop: 34 }}>
|
||||
<Link to="/site" className="btn btn-primary">
|
||||
Enter the Website
|
||||
</Link>
|
||||
<Link to="/wiki" className="btn btn-ghost">
|
||||
Open the Wiki
|
||||
</Link>
|
||||
</div>
|
||||
<div style={{ position: 'absolute', inset: 0, padding: '0 max(18px,calc((100% - 1080px)/2))' }}>
|
||||
{elements.map((el) => (
|
||||
<HeroElement key={el.id} element={el} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -527,6 +527,25 @@ button[disabled] {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* ===== Hero canvas editor ===== */
|
||||
.hero-el-editable {
|
||||
outline: 1px dashed rgba(127, 153, 189, 0.45);
|
||||
outline-offset: 2px;
|
||||
user-select: none;
|
||||
touch-action: none; /* let Pointer Events drive drag on touch */
|
||||
}
|
||||
.hero-el-editable:hover {
|
||||
outline-color: var(--accent);
|
||||
}
|
||||
.hero-el-editable.is-selected {
|
||||
outline: 2px solid var(--accent);
|
||||
}
|
||||
.hero-canvas-grid {
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(127, 153, 189, 0.18) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(127, 153, 189, 0.18) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
/* ===== Admin tables ===== */
|
||||
.adm-table {
|
||||
width: 100%;
|
||||
|
||||
@@ -8,6 +8,7 @@ const PUBLIC_KEYS = [
|
||||
'homepage_teaser',
|
||||
'contact_email',
|
||||
'site_title',
|
||||
'hero_layout', // portal hero composition (JSON). Draft key stays admin-only.
|
||||
]
|
||||
|
||||
async function get(key) {
|
||||
|
||||
Reference in New Issue
Block a user