Compare commits
8 Commits
a5a8c1930c
...
438d252c05
| Author | SHA1 | Date | |
|---|---|---|---|
| 438d252c05 | |||
| fecd28238d | |||
| 73eac2a138 | |||
| f69c86f737 | |||
| 785090eb97 | |||
| ccad727ec3 | |||
| 578bffc51f | |||
| 30c2a30c80 |
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 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 />} />
|
||||||
|
|||||||
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.
|
// 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} />
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
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', 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>
|
||||||
|
|||||||
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 { 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user