7 Commits

Author SHA1 Message Date
fecd28238d Merge pull request 'hero-feature' (#4) from hero-feature into main
Reviewed-on: UOM/website#4
2026-06-28 14:22:41 +00:00
73eac2a138 Hero editor: larger faithful canvas + real moon from the hero art
Addresses feedback that the editor was too small/cramped (elements overlapping)
and that the generated CSS moon looked bad.

- AdminLayout: the /admin/hero view now uses the full content width (no 1000px cap)
- HeroEditor canvas is a scaled 1280x720 "stage" (transform: scale to fit the
  column, capped at ~66vh). Because viewport-unit fonts and % positions scale
  together, the canvas is now a faithful miniature of the live hero — the default
  text block and CTA buttons no longer overlap. Drag snaps to the 8px stage grid;
  resize math is scale-aware.
- Moon element now renders the actual moon cropped from the hero artwork
  (client/public/assets/img/hero-moon.png, circular alpha mask) instead of the CSS
  dot; MoonPanel exposes size + glow.

Verified: at 1440px the canvas is ~785x442 with the panel beside it, default
elements don't overlap, and the moon loads the real image. Build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 09:13:22 -05:00
f69c86f737 Hero Phase 4: moon/badge/image elements + resize + snap grid
Final phase of the hero canvas editor (see HERO_EDITOR.md) — v1 complete.

- element tray adds moon, badge, and image; property panels:
  - moon: size / glow / color
  - badge: text / background / text color / corner radius
  - image: upload (/admin/uploads, >1MB warning) / width% / alt
- corner resize handle on selected elements (image→width%, moon→size,
  text_block→box width)
- 8px snap-grid toggle with a faint canvas grid overlay; drag snaps when on
- HeroElement: image element shows an "Upload an image" placeholder until a
  source is set (a srcless image never ships live)

Verified in-browser: all five element types add + edit; moon resized 64->104px
via the handle; snap grid overlays; a published moon + badge render on the live
portal; no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 08:47:05 -05:00
785090eb97 Hero Phase 3: element select / drag / edit (text_block + buttons)
Third phase of the hero canvas editor (see HERO_EDITOR.md).

- HeroElement: editor mode — inner content made non-interactive so the wrapper
  handles select/drag; selection outline; box width now canvas-relative
  (calc(100% - 36px)) so text blocks fit the smaller editor canvas
- HeroEditor: element tray (+ Text / + Buttons), click-to-select, native
  Pointer Events drag (position as % of the canvas, clamped), Delete key + panel
  delete, z-order (send back / bring forward), and per-type property panels:
  - text_block: per-line text / tag / font size (px) / color / bold, add+remove
    lines, alignment
  - buttons: per-item label / path / variant, add+remove, alignment
  empty-canvas click deselects (back to the background panel)
- theme.css: .hero-el-editable outline/hover/selected + grid helper

Verified in-browser: selecting shows the line editor, editing updates the canvas
live, drag repositions, add/delete and z-order work, deselect returns to the
background panel; no console errors. Moon/badge/image + resize + snap are Phase 4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 08:40:52 -05:00
ccad727ec3 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>
2026-06-28 02:32:09 -05:00
578bffc51f Hero Phase 1: layout data path + portal renderer
First phase of the hero canvas editor (see HERO_EDITOR.md).

- settings.model: add hero_layout to PUBLIC_KEYS so the portal receives it
  (corrects the design doc — public settings is a whitelist, not getAll();
  hero_layout_draft stays admin-only)
- new HeroElement.jsx: renders one layout element by type (text_block,
  buttons, moon, badge, image); absolute % positioning with anchor; shared
  by the portal now and the editor canvas later
- MoonDot: optional color override for the hero moon element
- Portal.jsx: parse hero_layout (version-checked, try/catch), render elements
  sorted by z; fall back to a DEFAULT_LAYOUT built from the current hero so the
  page is byte-for-byte unchanged until staff publish their own

No schema change. Verified: default render matches the old hero; publishing a
hero_layout re-renders the portal; the draft key is not exposed publicly; client
builds; no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 02:18:09 -05:00
30c2a30c80 Add hero canvas editor spec (corrected to current code)
Build contract for the WYSIWYG portal-hero editor on hero-feature, derived
from the design doc and corrected against the codebase:
- public settings is a whitelist (getPublic/PUBLIC_KEYS), so hero_layout must
  be added there — the doc's "no backend changes" was wrong
- moon is the reusable MoonDot component; route vs nav live in App.jsx vs
  AdminLayout.jsx; admin content is 1000px (canvas scales to fit)

Locked decisions: full v1, buttons as a first-class element type, pre-populate
the current hero on first run, native Pointer Events for drag. Phased plan
with per-phase exit checks. No schema change (JSON in settings).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 02:08:21 -05:00
11 changed files with 1062 additions and 43 deletions

134
HERO_EDITOR.md Normal file
View 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).

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -23,6 +23,7 @@ import AdminLayout from './routes/admin/AdminLayout.jsx'
import Dashboard from './routes/admin/views/Dashboard.jsx' import Dashboard from './routes/admin/views/Dashboard.jsx'
import PostsAdmin from './routes/admin/views/PostsAdmin.jsx' import PostsAdmin from './routes/admin/views/PostsAdmin.jsx'
import WikiAdmin from './routes/admin/views/WikiAdmin.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 SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx' import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
@@ -66,6 +67,7 @@ export default function App() {
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="posts" element={<PostsAdmin />} /> <Route path="posts" element={<PostsAdmin />} />
<Route path="wiki" element={<WikiAdmin />} /> <Route path="wiki" element={<WikiAdmin />} />
<Route path="hero" element={<HeroEditor />} />
<Route path="settings" element={<SettingsAdmin />} /> <Route path="settings" element={<SettingsAdmin />} />
<Route path="activity" element={<ActivityAdmin />} /> <Route path="activity" element={<ActivityAdmin />} />
<Route path="users" element={<UsersAdmin />} /> <Route path="users" element={<UsersAdmin />} />

View 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>
)
}

View File

@@ -1,9 +1,8 @@
// The little glowing moon used in the logo, login, and maintenance screens. // The little glowing moon used in the logo, login, maintenance screens, and the
export default function MoonDot({ size = 13, glow = 0.45 }) { // hero canvas. `color` overrides the radial-gradient start point (else the CSS
return ( // .moon default is used).
<span export default function MoonDot({ size = 13, glow = 0.45, color }) {
className="moon" const style = { width: size, height: size, boxShadow: `0 0 ${size * 0.8}px rgba(216,226,239,${glow})` }
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} />
)
} }

View 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' },
],
},
},
],
}
}

View File

@@ -8,6 +8,7 @@ const NAV = [
{ to: '/admin', label: 'Dashboard', end: true }, { to: '/admin', label: 'Dashboard', end: true },
{ to: '/admin/posts', label: 'Posts' }, { to: '/admin/posts', label: 'Posts' },
{ to: '/admin/wiki', label: 'Wiki' }, { to: '/admin/wiki', label: 'Wiki' },
{ to: '/admin/hero', label: 'Hero Editor' },
{ to: '/admin/settings', label: 'Settings' }, { to: '/admin/settings', label: 'Settings' },
{ to: '/admin/activity', label: 'Activity' }, { to: '/admin/activity', label: 'Activity' },
{ to: '/admin/users', label: 'Users' }, { to: '/admin/users', label: 'Users' },
@@ -17,6 +18,7 @@ const TITLES = {
'/admin': 'Dashboard', '/admin': 'Dashboard',
'/admin/posts': 'Posts', '/admin/posts': 'Posts',
'/admin/wiki': 'Wiki Pages', '/admin/wiki': 'Wiki Pages',
'/admin/hero': 'Hero Editor',
'/admin/settings': 'Site Settings', '/admin/settings': 'Site Settings',
'/admin/activity': 'Activity Log', '/admin/activity': 'Activity Log',
'/admin/users': 'Users', '/admin/users': 'Users',
@@ -39,6 +41,8 @@ export default function AdminLayout() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const title = TITLES[location.pathname] || 'Admin' 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)' const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt). // Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
@@ -145,7 +149,7 @@ export default function AdminLayout() {
</div> </div>
</header> </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 /> <Outlet />
</div> </div>
</main> </main>

View 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 &amp; 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>
)
}

View File

@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx' import PublicLayout from '../../components/PublicLayout.jsx'
import HeroElement from '../../components/HeroElement.jsx'
import { useSite } from '../../contexts/SiteContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx'
import { api } from '../../api/client.js'
const HERO_BG = import { defaultLayout, parseLayout, heroBackground } from '../../lib/heroLayout.js'
"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')"
const QUICK = [ const QUICK = [
{ label: 'News', to: '/site/news' }, { 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() { export default function Portal() {
const { settings } = useSite() const { settings } = useSite()
const teaser = const teaser =
settings.homepage_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.' '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 ( return (
<PublicLayout header={false}> <PublicLayout header={false}>
<main style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}> <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 <section
style={{ style={{
position: 'relative', position: 'relative',
display: 'grid',
alignContent: 'center',
minHeight: 'clamp(600px,72vh,860px)', minHeight: 'clamp(600px,72vh,860px)',
padding: '96px max(18px,calc((100% - 1080px)/2)) 96px',
overflow: 'hidden', overflow: 'hidden',
textAlign: 'center', ...bgStyle,
backgroundColor: 'var(--bg-deep)',
backgroundImage: HERO_BG,
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
backgroundSize: 'cover',
}} }}
> >
<div style={{ maxWidth: 760, margin: '0 auto', textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}> <div style={{ position: 'absolute', inset: 0, padding: '0 max(18px,calc((100% - 1080px)/2))' }}>
<p className="eyebrow" style={{ color: '#c2d2e6', letterSpacing: '0.22em' }}> {elements.map((el) => (
Private shard project <HeroElement key={el.id} element={el} />
</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> </div>
</section> </section>

View File

@@ -527,6 +527,25 @@ button[disabled] {
text-decoration: line-through; 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 ===== */ /* ===== Admin tables ===== */
.adm-table { .adm-table {
width: 100%; width: 100%;

View File

@@ -8,6 +8,7 @@ const PUBLIC_KEYS = [
'homepage_teaser', 'homepage_teaser',
'contact_email', 'contact_email',
'site_title', 'site_title',
'hero_layout', // portal hero composition (JSON). Draft key stays admin-only.
] ]
async function get(key) { async function get(key) {