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,22 @@
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import Maintenance from '../routes/public/Maintenance.jsx'
// Wraps the public site. When the shard is in maintenance, visitors see the
// coming-soon page; a logged-in admin sees the real site (live preview).
export default function MaintenanceGate({ children }) {
const { mode, loading } = useSite()
const { user, loading: authLoading } = useAuth()
if (loading || authLoading) {
return (
<div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', background: 'var(--bg-deep)' }}>
<span className="spin" />
</div>
)
}
if (mode === 'maintenance' && !user) {
return <Maintenance />
}
return children
}

View File

@@ -0,0 +1,70 @@
import { useEffect } from 'react'
// Simple centered modal used by the admin editors.
export default function Modal({ title, onClose, children, footer, width = 560 }) {
useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div
onMouseDown={onClose}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(6,9,13,0.66)',
backdropFilter: 'blur(3px)',
display: 'grid',
placeItems: 'center',
padding: 18,
zIndex: 100,
}}
>
<div
onMouseDown={(e) => e.stopPropagation()}
className="panel"
style={{ width: '100%', maxWidth: width, maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '18px 22px',
borderBottom: '1px solid var(--line-soft)',
}}
>
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
{title}
</h2>
<button
onClick={onClose}
className="sans"
style={{ border: 'none', background: 'transparent', color: 'var(--muted)', fontSize: '1.4rem', cursor: 'pointer', lineHeight: 1 }}
aria-label="Close"
>
×
</button>
</div>
<div style={{ padding: 22, overflow: 'auto' }}>{children}</div>
{footer && (
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: 10,
padding: '16px 22px',
borderTop: '1px solid var(--line-soft)',
}}
>
{footer}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,9 @@
// The little glowing moon used in the logo, login, and maintenance screens.
export default function MoonDot({ size = 13, glow = 0.45 }) {
return (
<span
className="moon"
style={{ width: size, height: size, boxShadow: `0 0 ${size * 0.8}px rgba(216,226,239,${glow})` }}
/>
)
}

View File

@@ -0,0 +1,14 @@
// The eyebrow + title + lead block at the top of most pages.
export default function PageHeader({ eyebrow, title, lead, center = false }) {
return (
<section style={{ marginBottom: 40, textAlign: center ? 'center' : 'left' }}>
{eyebrow && <p className="eyebrow">{eyebrow}</p>}
<h1 className="h1">{title}</h1>
{lead && (
<p className="lead" style={{ maxWidth: 680, marginLeft: center ? 'auto' : undefined, marginRight: center ? 'auto' : undefined }}>
{lead}
</p>
)}
</section>
)
}

View File

@@ -0,0 +1,27 @@
// Consistent loading / error / empty states for data-driven sections.
export function Loading({ label = 'Loading…' }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, color: 'var(--muted)', padding: '24px 0' }}>
<span className="spin" /> {label}
</div>
)
}
export function ErrorState({ message = 'Something went wrong.' }) {
return (
<div className="note" style={{ borderLeftColor: '#b4665f' }}>
{message}
</div>
)
}
export function EmptyState({ children }) {
return (
<div
className="panel"
style={{ padding: '28px', color: 'var(--muted)', textAlign: 'center', fontFamily: 'var(--sans)', fontSize: '0.95rem' }}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,13 @@
import SiteHeader from './SiteHeader.jsx'
import SiteFooter from './SiteFooter.jsx'
// Standard page chrome for the public site + wiki.
export default function PublicLayout({ section = 'website', header = true, children }) {
return (
<div className="page">
{header && <SiteHeader section={section} />}
{children}
<SiteFooter />
</div>
)
}

View File

@@ -0,0 +1,20 @@
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext.jsx'
// Gate for /admin/* — redirects to the login screen when not authenticated.
export default function RequireAuth({ children }) {
const { user, loading } = useAuth()
const location = useLocation()
if (loading) {
return (
<div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', background: 'var(--bg-deep)' }}>
<span className="spin" />
</div>
)
}
if (!user) {
return <Navigate to="/admin/login" state={{ from: location }} replace />
}
return children
}

View File

@@ -0,0 +1,36 @@
import { Link } from 'react-router-dom'
import { useSite } from '../contexts/SiteContext.jsx'
export default function SiteFooter() {
const { contactEmail } = useSite()
return (
<footer
className="sans"
style={{
borderTop: '1px solid var(--line)',
padding: '28px 16px',
color: 'var(--muted)',
textAlign: 'center',
fontSize: '0.9rem',
background: 'rgba(9,13,18,0.6)',
}}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
<span>UOMysticmoon is an independent private shard project.</span>
<span style={{ color: 'var(--dim)', fontSize: '0.84rem' }}>
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
{contactEmail}
</a>
&nbsp;·&nbsp;
<Link to="/site/status" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Shard Status
</Link>
&nbsp;·&nbsp;
<Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}>
Admin
</Link>
</span>
</div>
</footer>
)
}

View File

@@ -0,0 +1,73 @@
import { Link } from 'react-router-dom'
import MoonDot from './MoonDot.jsx'
const NAV = {
website: [
{ label: 'News', to: '/site/news' },
{ label: 'Screenshots', to: '/site/screenshots' },
{ label: 'Five on Friday', to: '/site/five-on-friday' },
{ label: 'Newsletter', to: '/site/newsletter' },
{ label: 'About', to: '/site/about' },
{ label: 'Wiki', to: '/wiki' },
],
wiki: [
{ label: 'Website', to: '/site' },
{ label: 'New Player Guide', to: '/wiki/new-player-guide' },
{ label: 'Maps & Atlas', to: '/wiki/maps-atlas' },
{ label: 'Systems', to: '/wiki/systems' },
{ label: 'Rules', to: '/wiki/rules' },
],
}
export default function SiteHeader({ section = 'website' }) {
const links = NAV[section] || NAV.website
return (
<header
style={{
borderBottom: '1px solid var(--line)',
background: 'rgba(9,13,18,0.86)',
backdropFilter: 'blur(8px)',
position: 'sticky',
top: 0,
zIndex: 30,
}}
>
<div
className="shell"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 20,
padding: '14px 0',
flexWrap: 'wrap',
}}
>
<Link
to="/"
className="display"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
fontSize: '1.2rem',
letterSpacing: '0.05em',
color: 'var(--accent-bright)',
textDecoration: 'none',
fontWeight: 600,
}}
>
<MoonDot />
UOMysticmoon
</Link>
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
{links.map((l) => (
<Link key={l.to + l.label} to={l.to} className="pill">
{l.label}
</Link>
))}
</nav>
</div>
</header>
)
}