Frontend update
This commit is contained in:
44
client/src/routes/public/About.jsx
Normal file
44
client/src/routes/public/About.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
client/src/routes/public/FiveOnFriday.jsx
Normal file
49
client/src/routes/public/FiveOnFriday.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
client/src/routes/public/Maintenance.jsx
Normal file
62
client/src/routes/public/Maintenance.jsx
Normal 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} · {' '}
|
||||
<Link to="/admin/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Admin
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
51
client/src/routes/public/News.jsx
Normal file
51
client/src/routes/public/News.jsx
Normal 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
|
||||
}
|
||||
136
client/src/routes/public/Newsletter.jsx
Normal file
136
client/src/routes/public/Newsletter.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
client/src/routes/public/NewsletterIssue.jsx
Normal file
61
client/src/routes/public/NewsletterIssue.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
client/src/routes/public/Portal.jsx
Normal file
112
client/src/routes/public/Portal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
client/src/routes/public/Screenshots.jsx
Normal file
62
client/src/routes/public/Screenshots.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
client/src/routes/public/Status.jsx
Normal file
86
client/src/routes/public/Status.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
client/src/routes/public/Website.jsx
Normal file
40
client/src/routes/public/Website.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user