Frontend update
This commit is contained in:
22
client/src/components/MaintenanceGate.jsx
Normal file
22
client/src/components/MaintenanceGate.jsx
Normal 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
|
||||
}
|
||||
70
client/src/components/Modal.jsx
Normal file
70
client/src/components/Modal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
client/src/components/MoonDot.jsx
Normal file
9
client/src/components/MoonDot.jsx
Normal 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})` }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
14
client/src/components/PageHeader.jsx
Normal file
14
client/src/components/PageHeader.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
client/src/components/PageState.jsx
Normal file
27
client/src/components/PageState.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
client/src/components/PublicLayout.jsx
Normal file
13
client/src/components/PublicLayout.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
client/src/components/RequireAuth.jsx
Normal file
20
client/src/components/RequireAuth.jsx
Normal 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
|
||||
}
|
||||
36
client/src/components/SiteFooter.jsx
Normal file
36
client/src/components/SiteFooter.jsx
Normal 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>
|
||||
·
|
||||
<Link to="/site/status" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Shard Status
|
||||
</Link>
|
||||
·
|
||||
<Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}>
|
||||
Admin
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
73
client/src/components/SiteHeader.jsx
Normal file
73
client/src/components/SiteHeader.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user