Frontend update

This commit is contained in:
2026-06-26 21:51:27 -05:00
parent eef79e2403
commit 6dba6a017c
48 changed files with 5004 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
export default function About() {
const { contactEmail } = useSite()
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader eyebrow="About" title="About Mysticmoon" />
<div className="prose">
<p>
Mysticmoon is an independent, privately-run Ultima Online shard built by a small group of long-time players.
It is not affiliated with or endorsed by the owners of Ultima Online it is a labor of love for the old
worlds and the friendships made in them.
</p>
<p>
Our aim is a calm, hand-tended world: a contested wilderness worth exploring, safe towns worth living in,
and systems that reward curiosity over grind. We are building slowly and in the open, sharing news,
screenshots, and guides as the world comes online.
</p>
<h2>What to expect</h2>
<ul>
<li>A hybrid ruleset safe towns, a dangerous wild.</li>
<li>Custom crafting, housing, and exploration content.</li>
<li>A small, friendly population and an active wiki.</li>
</ul>
</div>
<section className="note" style={{ marginTop: 30 }}>
<h3 className="display" style={{ margin: '0 0 6px', fontSize: '1.15rem', color: 'var(--head)' }}>
Get in touch
</h3>
<p style={{ margin: 0, color: 'var(--muted)' }}>
Questions, ideas, or want to help build? Reach us at{' '}
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
{contactEmail}
</a>
.
</p>
</section>
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,49 @@
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { longDate } from '../../lib/format.js'
import { api } from '../../api/client.js'
export default function FiveOnFriday() {
const { loading, error, data } = useAsync(() => api.posts('five-on-friday'))
const issues = data || []
return (
<PublicLayout section="website">
<div className="shell-mid page-body">
<PageHeader
eyebrow="Community"
title="Five on Friday"
lead="Five short notes from the week — what we built, what is next, and one small thing we are excited about."
/>
<section style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{loading && <Loading />}
{error && <ErrorState message="Could not load Five on Friday right now." />}
{!loading && !error && issues.length === 0 && (
<EmptyState>No Five on Friday posts yet the first one is coming soon.</EmptyState>
)}
{issues.map((it) => (
<article key={it.id} className="panel" style={{ padding: 30 }}>
<div
className="sans"
style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18, fontSize: '0.74rem', letterSpacing: '0.08em', textTransform: 'uppercase' }}
>
<span style={{ color: 'var(--accent)', fontWeight: 700 }}>Five on Friday</span>
<span className="dim">{longDate(it.published_at || it.created_at)}</span>
</div>
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.5rem', color: 'var(--head)' }}>
{it.title}
</h2>
{it.body ? (
<div className="prose" dangerouslySetInnerHTML={{ __html: it.body }} />
) : (
it.excerpt && <p style={{ margin: 0, color: 'var(--text)' }}>{it.excerpt}</p>
)}
</article>
))}
</section>
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,62 @@
import { Link } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
const HERO_BG =
"linear-gradient(180deg,rgba(11,15,20,0.55) 0%,rgba(11,15,20,0.74) 60%,rgba(11,15,20,0.92) 100%),url('/assets/img/uomysticmoon-main-hero.png')"
export default function Maintenance() {
const { settings, contactEmail } = useSite()
const message =
settings.maintenance_message ||
'Mysticmoon is in maintenance while we shape its towns, roads, and dungeons. The gates will open soon. Until then, follow along as the world wakes.'
return (
<main
style={{
minHeight: '100vh',
display: 'grid',
alignContent: 'center',
justifyItems: 'center',
textAlign: 'center',
padding: '80px max(18px,calc((100% - 760px)/2))',
overflow: 'hidden',
backgroundColor: 'var(--bg-deep)',
backgroundImage: HERO_BG,
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundSize: 'cover',
}}
>
<div style={{ maxWidth: 640, textShadow: '0 2px 22px rgba(0,0,0,0.85)' }}>
<div style={{ marginBottom: 26 }}>
<MoonDot size={18} glow={0.6} />
</div>
<p className="eyebrow" style={{ color: '#c2d2e6', letterSpacing: '0.24em' }}>
Building beneath the moon
</p>
<h1
className="display"
style={{ margin: 0, fontSize: 'clamp(2.6rem,7vw,4.6rem)', lineHeight: 1.05, letterSpacing: '0.02em' }}
>
The world is not yet open
</h1>
<p style={{ maxWidth: 520, margin: '24px auto 0', color: '#cdd6e0', fontSize: '1.14rem' }}>{message}</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'center', marginTop: 34 }}>
<Link to="/site/news" className="btn btn-primary">
Read the news
</Link>
<a href={`mailto:${contactEmail}`} className="btn btn-ghost">
Contact us
</a>
</div>
<p className="sans" style={{ margin: '40px 0 0', color: '#7a8696', fontSize: '0.82rem' }}>
{contactEmail} &nbsp;·&nbsp;{' '}
<Link to="/admin/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Admin
</Link>
</p>
</div>
</main>
)
}

View File

@@ -0,0 +1,51 @@
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { longDate } from '../../lib/format.js'
import { api } from '../../api/client.js'
export default function News() {
const { loading, error, data } = useAsync(() => api.posts('news'))
const posts = data || []
return (
<PublicLayout section="website">
<div className="shell-mid page-body">
<PageHeader
eyebrow="Development"
title="News & Updates"
lead="Progress notes and announcements as Mysticmoon takes shape."
/>
<section style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{loading && <Loading />}
{error && <ErrorState message="Could not load news right now." />}
{!loading && !error && posts.length === 0 && <EmptyState>No news posts yet check back soon.</EmptyState>}
{posts.map((p) => (
<article key={p.id} className="panel" style={{ padding: 28 }}>
<div
className="sans"
style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12, fontSize: '0.74rem', letterSpacing: '0.08em', textTransform: 'uppercase' }}
>
<span style={{ color: 'var(--accent)', fontWeight: 700 }}>News</span>
<span className="dim">{longDate(p.published_at || p.created_at)}</span>
</div>
<h2 className="display" style={{ margin: '0 0 10px', fontSize: '1.55rem', color: 'var(--head)' }}>
{p.title}
</h2>
{(p.excerpt || p.body) && (
<p style={{ margin: 0, color: 'var(--text)', fontSize: '1.04rem' }}>{p.excerpt || stripHtml(p.body)}</p>
)}
</article>
))}
</section>
</div>
</PublicLayout>
)
}
function stripHtml(html) {
if (!html) return ''
const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
return text.length > 280 ? text.slice(0, 280) + '…' : text
}

View File

@@ -0,0 +1,136 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { monthTile } from '../../lib/format.js'
import { api } from '../../api/client.js'
import { useSite } from '../../contexts/SiteContext.jsx'
export default function Newsletter() {
const { loading, error, data } = useAsync(() => api.posts('newsletter'))
const issues = data || []
return (
<PublicLayout section="website">
<div className="shell-mid page-body">
<PageHeader
eyebrow="Long-form"
title="Monthly Newsletter"
lead="A fuller monthly summary for players who want the whole picture."
/>
<SubscribeBox />
<section style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{loading && <Loading />}
{error && <ErrorState message="Could not load newsletter issues right now." />}
{!loading && !error && issues.length === 0 && <EmptyState>No issues published yet.</EmptyState>}
{issues.map((i) => {
const tile = monthTile(i.published_at || i.created_at)
return (
<Link
key={i.id}
to={`/site/newsletter/${i.slug || i.id}`}
className="panel"
style={{ display: 'flex', gap: 20, alignItems: 'center', padding: '22px 24px', textDecoration: 'none' }}
>
<div
className="display"
style={{
flex: 'none',
width: 64,
height: 64,
borderRadius: 8,
border: '1px solid var(--line)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(11,22,48,0.5)',
}}
>
<span style={{ color: 'var(--accent)', fontSize: '0.66rem', letterSpacing: '0.1em' }}>{tile.mon}</span>
<span style={{ color: 'var(--head)', fontSize: '1.4rem', lineHeight: 1 }}>{tile.num}</span>
</div>
<div style={{ flex: 1 }}>
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1.2rem', color: 'var(--head)' }}>
{i.title}
</h3>
<p className="muted" style={{ margin: 0, fontSize: '0.98rem' }}>
{i.excerpt || ''}
</p>
</div>
<span className="sans" style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.84rem' }}>
Read
</span>
</Link>
)
})}
</section>
</div>
</PublicLayout>
)
}
function SubscribeBox() {
const { contactEmail } = useSite()
const [email, setEmail] = useState('')
const [status, setStatus] = useState(null) // null | 'sending' | 'done' | 'mailto' | 'error'
async function onSubmit(e) {
e.preventDefault()
if (!email) return
setStatus('sending')
try {
const res = await api.contact({ email, message: `Newsletter subscription request from ${email}` })
setStatus(res && res.fallback === 'mailto' ? 'mailto' : 'done')
} catch {
setStatus('error')
}
}
return (
<section
style={{
display: 'flex',
alignItems: 'center',
gap: 14,
flexWrap: 'wrap',
padding: '22px 24px',
border: '1px solid var(--line)',
borderRadius: 10,
background: 'rgba(19,36,60,0.4)',
marginBottom: 34,
}}
>
<span style={{ color: '#dbe2ea', fontSize: '1.02rem', flex: 1, minWidth: 220 }}>
Get each issue in your inbox the day it ships.
</span>
{status === 'done' && <span className="muted sans" style={{ fontSize: '0.9rem' }}>Thanks we'll be in touch.</span>}
{status === 'mailto' && (
<a className="link-accent sans" href={`mailto:${contactEmail}?subject=Newsletter%20subscribe`}>
Email us to subscribe →
</a>
)}
{status !== 'done' && status !== 'mailto' && (
<form onSubmit={onSubmit} style={{ display: 'flex', gap: 10, flex: 'none', flexWrap: 'wrap' }}>
<input
type="email"
required
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input"
style={{ borderRadius: 999, minWidth: 200, width: 'auto' }}
/>
<button type="submit" className="btn btn-primary btn-sq" style={{ borderRadius: 999 }} disabled={status === 'sending'}>
{status === 'sending' ? 'Sending' : 'Subscribe'}
</button>
</form>
)}
{status === 'error' && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>Something went wrong.</span>}
</section>
)
}

View File

@@ -0,0 +1,61 @@
import { Link, useParams } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { longDate, monthTile } from '../../lib/format.js'
import { api } from '../../api/client.js'
export default function NewsletterIssue() {
const { id } = useParams()
const { loading, error, data: issue } = useAsync(() => api.post('newsletter', id), [id])
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
{loading && <Loading />}
{error && (
<ErrorState
message={error.status === 404 ? 'That newsletter issue could not be found.' : 'Could not load this issue.'}
/>
)}
{issue && <Issue issue={issue} />}
{(error || issue) && (
<p style={{ marginTop: 34 }}>
<Link to="/site/newsletter" className="pill">
All issues
</Link>
</p>
)}
</div>
</PublicLayout>
)
}
function Issue({ issue }) {
const tile = monthTile(issue.published_at || issue.created_at)
const label = `${tile.mon} ${tile.num}`.trim()
return (
<article>
<p className="sans" style={{ margin: '0 0 14px', display: 'flex', gap: 8, color: 'var(--dim)', fontSize: '0.82rem' }}>
<Link to="/site/newsletter" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Newsletter
</Link>
<span>/</span>
<span>{longDate(issue.published_at || issue.created_at)}</span>
</p>
<p className="eyebrow" style={{ letterSpacing: '0.16em' }}>
Issue {label}
</p>
<h1 className="display" style={{ margin: 0, fontSize: 'clamp(2.2rem,5vw,3.2rem)', lineHeight: 1.05, color: 'var(--head)' }}>
{issue.title}
</h1>
{issue.excerpt && <p style={{ margin: '14px 0 0', color: 'var(--muted)', fontSize: '1.1rem' }}>{issue.excerpt}</p>}
<div style={{ height: 1, background: 'var(--line)', margin: '28px 0' }} />
{issue.body ? (
<div className="prose" dangerouslySetInnerHTML={{ __html: issue.body }} />
) : (
<p className="muted">This issue has no content yet.</p>
)}
</article>
)
}

View File

@@ -0,0 +1,112 @@
import { Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
const HERO_BG =
"linear-gradient(90deg,rgba(11,15,20,0.34) 0%,rgba(11,15,20,0.5) 36%,rgba(11,15,20,0.78) 62%,rgba(11,15,20,0.66) 100%),linear-gradient(180deg,rgba(11,15,20,0.08) 0%,rgba(11,15,20,0.72) 100%),url('/assets/img/uomysticmoon-main-hero.png')"
const QUICK = [
{ label: 'News', to: '/site/news' },
{ label: 'Screenshots', to: '/site/screenshots' },
{ label: 'Five on Friday', to: '/site/five-on-friday' },
{ label: 'Monthly Newsletter', to: '/site/newsletter' },
{ label: 'About', to: '/site/about' },
]
const DESTINATIONS = [
{
kicker: 'Public portal',
title: 'Mysticmoon Website',
body: 'Updates, screenshots, newsletters, and weekly community posts from the shard.',
to: '/site',
},
{
kicker: 'Knowledge base',
title: 'Mysticmoon Wiki',
body: 'Guides, maps, systems, items, monsters, crafting, lore, and rules.',
to: '/wiki',
},
]
export default function Portal() {
const { settings } = useSite()
const teaser =
settings.homepage_teaser ||
'Mysticmoon is still being shaped beneath a midnight sky — a quiet preview for the news, screenshots, guides, and community notes to come as the world wakes.'
return (
<PublicLayout header={false}>
<main style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
<section
style={{
position: 'relative',
display: 'grid',
alignContent: 'center',
minHeight: 'clamp(600px,72vh,860px)',
padding: '96px max(18px,calc((100% - 1080px)/2)) 96px',
overflow: 'hidden',
textAlign: 'center',
backgroundColor: 'var(--bg-deep)',
backgroundImage: HERO_BG,
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
backgroundSize: 'cover',
}}
>
<div style={{ maxWidth: 760, margin: '0 auto', textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
<p className="eyebrow" style={{ color: '#c2d2e6', letterSpacing: '0.22em' }}>
Private shard project
</p>
<h1
className="display"
style={{ margin: 0, fontSize: 'clamp(3rem,8.5vw,5.75rem)', lineHeight: 1, letterSpacing: '0.02em' }}
>
UOMysticmoon
</h1>
<p style={{ margin: '22px auto 0', color: '#dbe2ea', fontSize: '1.32rem', fontStyle: 'italic' }}>
A private Ultima Online world in progress
</p>
<p style={{ maxWidth: 600, margin: '22px auto 0', color: '#c4cdd8', fontSize: '1.06rem' }}>{teaser}</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'center', marginTop: 34 }}>
<Link to="/site" className="btn btn-primary">
Enter the Website
</Link>
<Link to="/wiki" className="btn btn-ghost">
Open the Wiki
</Link>
</div>
</div>
</section>
<div className="shell" style={{ padding: '56px 0 12px' }}>
<nav className="grid-2" aria-label="Main destinations">
{DESTINATIONS.map((d) => (
<Link key={d.to} to={d.to} className="card" style={{ padding: 30 }}>
<span className="card-kicker" style={{ letterSpacing: '0.16em', marginBottom: 14 }}>
{d.kicker}
</span>
<strong
className="display"
style={{ fontSize: '1.7rem', color: 'var(--head)', marginBottom: 10, fontWeight: 600 }}
>
{d.title}
</strong>
<span className="muted">{d.body}</span>
</Link>
))}
</nav>
</div>
<div className="shell" style={{ padding: '24px 0 64px' }}>
<nav style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 10 }} aria-label="Quick links">
{QUICK.map((q) => (
<Link key={q.to} to={q.to} className="pill">
{q.label}
</Link>
))}
</nav>
</div>
</main>
</PublicLayout>
)
}

View File

@@ -0,0 +1,62 @@
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
const HATCH = 'repeating-linear-gradient(135deg,#141a21,#141a21 13px,#181f29 13px,#181f29 26px)'
export default function Screenshots() {
const { loading, error, data } = useAsync(() => api.posts('screenshots'))
const shots = data || []
return (
<PublicLayout section="website">
<div className="shell page-body">
<PageHeader
eyebrow="Gallery"
title="Gameplay Pictures"
lead="Glimpses of towns, dungeons, events, and daily life on the shard."
/>
{loading && <Loading />}
{error && <ErrorState message="Could not load the gallery right now." />}
{!loading && !error && shots.length === 0 && <EmptyState>No screenshots posted yet.</EmptyState>}
<section className="grid-3">
{shots.map((s) => (
<figure key={s.id} className="panel-flat" style={{ margin: 0, boxShadow: 'var(--shadow-card)' }}>
{s.image_url ? (
<img
src={s.image_url}
alt={s.title || ''}
style={{ display: 'block', width: '100%', aspectRatio: '16 / 10', objectFit: 'cover' }}
/>
) : (
<div
style={{
aspectRatio: '16 / 10',
display: 'flex',
alignItems: 'flex-end',
padding: 12,
background: HATCH,
color: 'var(--dim)',
fontFamily: 'ui-monospace,Menlo,monospace',
fontSize: '0.7rem',
}}
>
image · {s.slug || s.id}
</div>
)}
{(s.excerpt || s.title) && (
<figcaption
style={{ padding: '14px 16px', color: 'var(--text)', fontSize: '0.96rem', borderTop: '1px solid var(--line)' }}
>
{s.excerpt || s.title}
</figcaption>
)}
</figure>
))}
</section>
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,86 @@
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
export default function Status() {
const { loading, error, data } = useAsync(() => api.status())
const mode = data?.mode || 'live'
const statusMessage = data?.status_message || ''
const isLive = mode === 'live'
const stats = [
{ value: isLive ? 'Live' : 'Maint.', label: 'Site mode' },
{ value: isLive ? 'Open' : 'Closed', label: 'Public login' },
{ value: statusMessage || '—', label: 'Latest note' },
]
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader eyebrow="Live" title="Shard Status" />
{loading && <Loading />}
{error && <ErrorState message="Could not load status right now." />}
{!loading && !error && (
<>
<section
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '24px 26px',
border: `1px solid ${isLive ? 'rgba(95,185,138,0.45)' : '#5a4a2a'}`,
borderRadius: 10,
background: isLive
? 'linear-gradient(180deg,rgba(22,46,34,0.5),rgba(16,26,20,0.4))'
: 'linear-gradient(180deg,rgba(58,46,22,0.5),rgba(30,26,16,0.4))',
marginBottom: 24,
}}
>
<span
style={{
flex: 'none',
width: 12,
height: 12,
borderRadius: '50%',
background: isLive ? 'var(--mode-live)' : 'var(--mode-maint)',
boxShadow: `0 0 12px ${isLive ? 'rgba(95,185,138,0.7)' : 'rgba(230,194,106,0.7)'}`,
}}
/>
<div>
<strong
className="display"
style={{ display: 'block', fontSize: '1.2rem', color: isLive ? '#bfe6cf' : '#f0e3c4' }}
>
{isLive ? 'Live — the gates are open' : 'Maintenance — building in progress'}
</strong>
<span style={{ color: isLive ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
{statusMessage || (isLive ? 'The shard is online.' : 'The gates are closed while we shape the world. Public login is not open yet.')}
</span>
</div>
</section>
<section className="grid-3" style={{ gap: 14, marginBottom: 30 }}>
{stats.map((s) => (
<div key={s.label} className="panel" style={{ padding: 20, textAlign: 'center' }}>
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>
{s.value}
</div>
<div
className="sans"
style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 6 }}
>
{s.label}
</div>
</div>
))}
</section>
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,40 @@
import { Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
const CARDS = [
{ kicker: 'Gallery', title: 'Gameplay Pictures', body: 'Screenshots from towns, dungeons, events, and daily life on the shard.', to: '/site/screenshots' },
{ kicker: 'Updates', title: 'Development News', body: 'Progress notes, shard milestones, and public announcements.', to: '/site/news' },
{ kicker: 'Community', title: 'Five on Friday', body: 'Weekly questions, small previews, and notes from the team.', to: '/site/five-on-friday' },
{ kicker: 'Long-form', title: 'Monthly Newsletter', body: 'Fuller summaries for players who want the whole picture.', to: '/site/newsletter' },
{ kicker: 'Reference', title: 'Wiki', body: 'Guides and reference pages for the Mysticmoon world.', to: '/wiki' },
{ kicker: 'Live', title: 'Shard Status', body: 'Launch state, test windows, and known issues.', to: '/site/status' },
]
export default function Website() {
return (
<PublicLayout section="website">
<div className="shell page-body">
<PageHeader
center
eyebrow="Public portal"
title="Mysticmoon Website"
lead="A home for gameplay pictures, development updates, community posts, monthly newsletters, and weekly Five on Friday notes."
/>
<section className="grid-3">
{CARDS.map((c) => (
<Link key={c.to + c.title} to={c.to} className="card">
<span className="card-kicker">{c.kicker}</span>
<h3 className="display" style={{ margin: '0 0 8px', fontSize: '1.25rem', color: 'var(--head)' }}>
{c.title}
</h3>
<p className="muted" style={{ margin: 0, fontSize: '0.98rem' }}>
{c.body}
</p>
</Link>
))}
</section>
</div>
</PublicLayout>
)
}