Merge pull request 'Frontend update' (#1) from frontend into main

Reviewed-on: UOM/website#1
This commit is contained in:
2026-06-27 02:55:47 +00:00
48 changed files with 5004 additions and 0 deletions

11
.claude/launch.json Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "client",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--prefix", "client"],
"port": 5173
}
]
}

16
client/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>UOMysticmoon</title>
<meta name="description" content="UOMysticmoon — an independent private Ultima Online shard. News, screenshots, guides, and community notes." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1761
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
client/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "uomysticmoon-client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.2",
"vite": "^5.4.8"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

2
client/public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /admin

80
client/src/App.jsx Normal file
View File

@@ -0,0 +1,80 @@
import { Routes, Route, Navigate, Outlet } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext.jsx'
import { SiteProvider } from './contexts/SiteContext.jsx'
import MaintenanceGate from './components/MaintenanceGate.jsx'
import RequireAuth from './components/RequireAuth.jsx'
// Public
import Portal from './routes/public/Portal.jsx'
import Website from './routes/public/Website.jsx'
import News from './routes/public/News.jsx'
import Screenshots from './routes/public/Screenshots.jsx'
import FiveOnFriday from './routes/public/FiveOnFriday.jsx'
import Newsletter from './routes/public/Newsletter.jsx'
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
import About from './routes/public/About.jsx'
import Status from './routes/public/Status.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
// Admin
import AdminLogin from './routes/admin/AdminLogin.jsx'
import AdminLayout from './routes/admin/AdminLayout.jsx'
import Dashboard from './routes/admin/views/Dashboard.jsx'
import PostsAdmin from './routes/admin/views/PostsAdmin.jsx'
import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
export default function App() {
return (
<AuthProvider>
<SiteProvider>
<Routes>
{/* Public site — gated by maintenance mode (admins preview through it) */}
<Route
element={
<MaintenanceGate>
<Outlet />
</MaintenanceGate>
}
>
<Route path="/" element={<Portal />} />
<Route path="/site" element={<Website />} />
<Route path="/site/news" element={<News />} />
<Route path="/site/screenshots" element={<Screenshots />} />
<Route path="/site/five-on-friday" element={<FiveOnFriday />} />
<Route path="/site/newsletter" element={<Newsletter />} />
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
<Route path="/site/about" element={<About />} />
<Route path="/site/status" element={<Status />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} />
</Route>
{/* Admin */}
<Route path="/admin/login" element={<AdminLogin />} />
<Route
path="/admin"
element={
<RequireAuth>
<AdminLayout />
</RequireAuth>
}
>
<Route index element={<Dashboard />} />
<Route path="posts" element={<PostsAdmin />} />
<Route path="wiki" element={<WikiAdmin />} />
<Route path="settings" element={<SettingsAdmin />} />
<Route path="activity" element={<ActivityAdmin />} />
<Route path="users" element={<UsersAdmin />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</SiteProvider>
</AuthProvider>
)
}

87
client/src/api/client.js Normal file
View File

@@ -0,0 +1,87 @@
// Thin fetch wrapper. Always sends cookies (httpOnly JWT) and talks to the
// same-origin API (/api/v1) — proxied to the Express server in dev.
const BASE = '/api/v1'
class ApiError extends Error {
constructor(status, message, body) {
super(message)
this.status = status
this.body = body
}
}
async function req(path, { method = 'GET', body, headers, raw } = {}) {
const opts = { method, credentials: 'include', headers: { ...headers } }
if (body !== undefined) {
if (raw) {
opts.body = body // FormData — let the browser set the content-type
} else {
opts.headers['Content-Type'] = 'application/json'
opts.body = JSON.stringify(body)
}
}
const res = await fetch(BASE + path, opts)
const text = await res.text()
const data = text ? safeParse(text) : null
if (!res.ok) {
const message = (data && data.message) || res.statusText || 'Request failed'
throw new ApiError(res.status, message, data)
}
return data
}
function safeParse(text) {
try {
return JSON.parse(text)
} catch {
return text
}
}
export const api = {
// ----- auth -----
me: () => req('/auth/me'),
login: (username, password) => req('/auth/login', { method: 'POST', body: { username, password } }),
logout: () => req('/auth/logout', { method: 'POST' }),
// ----- public -----
publicSettings: () => req('/public/settings'),
status: () => req('/public/status'),
posts: (category) => req(`/public/posts/${category}`),
post: (category, idOrSlug) => req(`/public/posts/${category}/${idOrSlug}`),
wiki: () => req('/public/wiki'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
// ----- admin -----
admin: {
dashboard: () => req('/admin/dashboard'),
setSiteMode: (mode) => req('/admin/site-mode', { method: 'PUT', body: { mode } }),
listPosts: (category) => req(`/admin/posts${category ? `?category=${category}` : ''}`),
getPost: (id) => req(`/admin/posts/${id}`),
createPost: (data) => req('/admin/posts', { method: 'POST', body: data }),
updatePost: (id, data) => req(`/admin/posts/${id}`, { method: 'PUT', body: data }),
deletePost: (id) => req(`/admin/posts/${id}`, { method: 'DELETE' }),
publishPost: (id, published) =>
req(`/admin/posts/${id}/publish`, { method: 'PATCH', body: { published } }),
uploadImage: (file) => {
const fd = new FormData()
fd.append('image', file)
return req('/admin/posts/upload', { method: 'POST', body: fd, raw: true })
},
listWiki: () => req('/admin/wiki'),
getWiki: (slug) => req(`/admin/wiki/${slug}`),
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
updateWiki: (slug, data) => req(`/admin/wiki/${slug}`, { method: 'PUT', body: data }),
deleteWiki: (slug) => req(`/admin/wiki/${slug}`, { method: 'DELETE' }),
getSettings: () => req('/admin/settings'),
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
listUsers: () => req('/admin/users'),
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
},
}
export { ApiError }

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>
)
}

View File

@@ -0,0 +1,50 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
import { api } from '../api/client.js'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const refresh = useCallback(async () => {
try {
const data = await api.me()
setUser(data.user)
} catch {
setUser(null)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
refresh()
}, [refresh])
const login = useCallback(async (username, password) => {
const data = await api.login(username, password)
setUser(data.user)
return data.user
}, [])
const logout = useCallback(async () => {
try {
await api.logout()
} finally {
setUser(null)
}
}, [])
return (
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}

View File

@@ -0,0 +1,42 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
import { api } from '../api/client.js'
const SiteContext = createContext(null)
// Public site settings + mode (always reachable, even during maintenance).
export function SiteProvider({ children }) {
const [settings, setSettings] = useState({})
const [loading, setLoading] = useState(true)
const refresh = useCallback(async () => {
try {
const data = await api.publicSettings()
setSettings(data || {})
} catch {
setSettings({})
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
refresh()
}, [refresh])
const value = {
settings,
loading,
refresh,
mode: settings.site_mode || 'live',
siteTitle: settings.site_title || 'UOMysticmoon',
contactEmail: settings.contact_email || 'UOMysticmoon@gmail.com',
}
return <SiteContext.Provider value={value}>{children}</SiteContext.Provider>
}
export function useSite() {
const ctx = useContext(SiteContext)
if (!ctx) throw new Error('useSite must be used within SiteProvider')
return ctx
}

66
client/src/lib/format.js Normal file
View File

@@ -0,0 +1,66 @@
// Date + label helpers shared across pages.
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
function parse(value) {
if (!value) return null
const d = new Date(value)
return isNaN(d.getTime()) ? null : d
}
// "June 24, 2026"
export function longDate(value) {
const d = parse(value)
if (!d) return ''
return `${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`
}
// "Jun 24"
export function shortDate(value) {
const d = parse(value)
if (!d) return ''
return `${MONTHS[d.getMonth()].slice(0, 3)} ${d.getDate()}`
}
// "Jun 26 18:55"
export function dateTime(value) {
const d = parse(value)
if (!d) return ''
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${MONTHS[d.getMonth()].slice(0, 3)} ${d.getDate()} ${hh}:${mm}`
}
// { mon: 'JUN', num: "'26" } for the newsletter month tile
export function monthTile(value) {
const d = parse(value)
if (!d) return { mon: '—', num: '' }
return { mon: MONTHS[d.getMonth()].slice(0, 3).toUpperCase(), num: `'${String(d.getFullYear()).slice(2)}` }
}
// Compact relative time: "5m ago", "2h ago", "1d ago"
export function ago(value) {
const d = parse(value)
if (!d) return ''
const secs = Math.max(1, Math.floor((Date.now() - d.getTime()) / 1000))
if (secs < 60) return `${secs}s ago`
const mins = Math.floor(secs / 60)
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
const days = Math.floor(hrs / 24)
return `${days}d ago`
}
const CATEGORY_LABELS = {
news: 'News',
five_on_friday: 'Five on Friday',
newsletter: 'Newsletter',
screenshot: 'Screenshot',
}
export function categoryLabel(dbCategory) {
return CATEGORY_LABELS[dbCategory] || dbCategory
}

View File

@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react'
// Minimal data-fetching hook: runs `fn` on mount / when deps change.
export function useAsync(fn, deps = []) {
const [state, setState] = useState({ loading: true, error: null, data: null })
useEffect(() => {
let active = true
setState({ loading: true, error: null, data: null })
fn()
.then((data) => active && setState({ loading: false, error: null, data }))
.catch((error) => active && setState({ loading: false, error, data: null }))
return () => {
active = false
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
return state
}

13
client/src/main.jsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import './styles/theme.css'
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -0,0 +1,154 @@
import { useEffect } from 'react'
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
const NAV = [
{ to: '/admin', label: 'Dashboard', end: true },
{ to: '/admin/posts', label: 'Posts' },
{ to: '/admin/wiki', label: 'Wiki' },
{ to: '/admin/settings', label: 'Settings' },
{ to: '/admin/activity', label: 'Activity' },
{ to: '/admin/users', label: 'Users' },
]
const TITLES = {
'/admin': 'Dashboard',
'/admin/posts': 'Posts',
'/admin/wiki': 'Wiki Pages',
'/admin/settings': 'Site Settings',
'/admin/activity': 'Activity Log',
'/admin/users': 'Users',
}
const navBtnBase = {
textAlign: 'left',
borderRadius: 8,
padding: '10px 14px',
fontFamily: 'var(--sans)',
fontSize: '0.92rem',
textDecoration: 'none',
display: 'block',
transition: 'background .15s,color .15s',
}
export default function AdminLayout() {
const { user, logout } = useAuth()
const { mode } = useSite()
const navigate = useNavigate()
const location = useLocation()
const title = TITLES[location.pathname] || 'Admin'
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
useEffect(() => {
const meta = document.createElement('meta')
meta.name = 'robots'
meta.content = 'noindex, nofollow'
document.head.appendChild(meta)
return () => document.head.removeChild(meta)
}, [])
async function signOut() {
await logout()
navigate('/admin/login', { replace: true })
}
return (
<div className="admin-grid">
{/* Sidebar */}
<aside
style={{
borderRight: '1px solid var(--line)',
background: 'var(--bg)',
display: 'flex',
flexDirection: 'column',
position: 'sticky',
top: 0,
height: '100vh',
}}
>
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
<MoonDot />
<div>
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
UOMysticmoon
</div>
<div className="sans" style={{ color: 'var(--dim)', fontSize: '0.66rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>
Admin
</div>
</div>
</div>
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{NAV.map((n) => (
<NavLink
key={n.to}
to={n.to}
end={n.end}
style={({ isActive }) => ({
...navBtnBase,
background: isActive ? 'var(--blue)' : 'transparent',
color: isActive ? 'var(--ink)' : 'var(--muted)',
borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
})}
>
{n.label}
</NavLink>
))}
</nav>
<div style={{ padding: '14px 16px', borderTop: '1px solid var(--line-soft)' }}>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: '0.78rem', color: 'var(--muted)' }}>
<span style={{ width: 9, height: 9, borderRadius: '50%', background: modeDot, boxShadow: `0 0 8px ${modeDot}` }} />
Site is&nbsp;<strong style={{ color: 'var(--ink)', textTransform: 'capitalize' }}>{mode}</strong>
</div>
<button
onClick={signOut}
className="sans"
style={{ display: 'block', width: '100%', textAlign: 'center', border: '1px solid var(--line)', borderRadius: 8, padding: 9, color: 'var(--muted)', background: 'transparent', fontSize: '0.84rem', cursor: 'pointer' }}
>
Sign out
</button>
</div>
</aside>
{/* Main */}
<main style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
padding: '20px 32px',
borderBottom: '1px solid var(--line-soft)',
background: 'var(--bg)',
position: 'sticky',
top: 0,
zIndex: 10,
}}
>
<h1 className="display" style={{ margin: 0, fontSize: '1.5rem', color: 'var(--head)' }}>
{title}
</h1>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 14, fontSize: '0.84rem', color: 'var(--muted)' }}>
<a href="/" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
View site
</a>
<span
style={{ width: 30, height: 30, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '0.8rem', textTransform: 'uppercase' }}
>
{(user?.username || 'A').charAt(0)}
</span>
</div>
</header>
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: 1000, width: '100%' }}>
<Outlet />
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,124 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
const BG =
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
export default function AdminLogin() {
const { user, login } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const dest = location.state?.from?.pathname || '/admin'
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
// Already signed in → go straight to the panel.
useEffect(() => {
if (user) navigate(dest, { replace: true })
}, [user, dest, navigate])
async function onSubmit(e) {
e.preventDefault()
setError('')
setBusy(true)
try {
await login(username, password)
navigate(dest, { replace: true })
} catch (err) {
setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
setBusy(false)
}
}
return (
<main
style={{
minHeight: '100vh',
display: 'grid',
placeItems: 'center',
padding: '40px 18px',
overflow: 'hidden',
backgroundColor: 'var(--bg-deep)',
backgroundImage: BG,
backgroundPosition: 'center',
backgroundSize: 'cover',
}}
>
<div style={{ width: '100%', maxWidth: 400 }}>
<div style={{ textAlign: 'center', marginBottom: 26 }}>
<div style={{ marginBottom: 14 }}>
<MoonDot size={15} glow={0.55} />
</div>
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
UOMysticmoon
</h1>
<p className="sans" style={{ margin: '6px 0 0', color: '#9aa6b4', fontSize: '0.8rem', letterSpacing: '0.16em', textTransform: 'uppercase' }}>
Admin Panel
</p>
</div>
<form
onSubmit={onSubmit}
style={{
border: '1px solid var(--line)',
borderRadius: 12,
padding: 28,
background: 'linear-gradient(180deg,rgba(25,34,49,0.92),rgba(20,26,33,0.92))',
backdropFilter: 'blur(6px)',
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
}}
>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Username</span>
<input
type="text"
autoComplete="username"
autoFocus
value={username}
onChange={(e) => setUsername(e.target.value)}
className="input"
/>
</label>
<label style={{ display: 'block', marginBottom: 22 }}>
<span className="field-label">Password</span>
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input"
/>
</label>
{error && (
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>
{error}
</p>
)}
<button
type="submit"
disabled={busy}
className="btn btn-primary"
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
>
{busy ? 'Signing in…' : 'Sign in'}
</button>
<p className="sans" style={{ margin: '16px 0 0', textAlign: 'center', color: 'var(--dim)', fontSize: '0.76rem' }}>
Protected area not indexed. Sessions expire after 1 day.
</p>
</form>
<p style={{ textAlign: 'center', margin: '20px 0 0' }}>
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
Back to site
</Link>
</p>
</div>
</main>
)
}

View File

@@ -0,0 +1,57 @@
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import { formatDetail } from './Dashboard.jsx'
export default function ActivityAdmin() {
const { loading, error, data } = useAsync(() => api.admin.activity(100))
const rows = data || []
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load the activity log." />
return (
<section>
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Action</th>
<th className="adm-th">Detail</th>
<th className="adm-th">User</th>
<th className="adm-th">IP</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
No activity recorded yet.
</td>
</tr>
)}
{rows.map((a) => (
<tr key={a.id}>
<td className="adm-td">
<span style={{ fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)', fontSize: '0.82rem' }}>
{a.action}
</span>
</td>
<td className="adm-td">{formatDetail(a)}</td>
<td className="adm-td" style={{ color: 'var(--text)' }}>
{a.username || '—'}
</td>
<td className="adm-td dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem' }}>
{a.ip || '—'}
</td>
<td className="adm-td dim">{dateTime(a.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}

View File

@@ -0,0 +1,138 @@
import { useCallback, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { ago, dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
export default function Dashboard() {
const { refresh: refreshSite } = useSite()
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const { loading, error, data } = useAsync(
() => Promise.all([api.admin.dashboard(), api.admin.listPosts(), api.admin.listWiki()]),
[tick],
)
const [busy, setBusy] = useState(false)
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load the dashboard." />
const [dash, posts, wiki] = data
const mode = dash.site_mode || 'live'
const isLive = mode === 'live'
const modeDot = isLive ? 'var(--mode-live)' : 'var(--mode-maint)'
const published = posts.filter((p) => p.published).length
const stats = [
{ value: published, label: 'Published posts' },
{ value: posts.length - published, label: 'Drafts' },
{ value: wiki.length, label: 'Wiki pages' },
{ value: dash.counts?.users ?? 0, label: 'Users' },
]
async function toggle() {
setBusy(true)
try {
await api.admin.setSiteMode(isLive ? 'maintenance' : 'live')
await refreshSite()
reload()
} finally {
setBusy(false)
}
}
const changed = dash.last_change || {}
return (
<section>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: 18,
padding: 24,
border: '1px solid var(--line)',
borderRadius: 12,
background: 'var(--panel-grad)',
marginBottom: 24,
}}
>
<div>
<div className="card-kicker" style={{ marginBottom: 8 }}>
Site mode
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ width: 11, height: 11, borderRadius: '50%', background: modeDot, boxShadow: `0 0 10px ${modeDot}` }} />
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)', textTransform: 'capitalize' }}>
{mode}
</span>
</div>
<div className="sans dim" style={{ fontSize: '0.8rem', marginTop: 6 }}>
{changed.by ? `Changed by ${changed.by}` : 'No changes recorded'}
{changed.at ? ` · ${dateTime(changed.at)}` : ''}
</div>
</div>
<button
onClick={toggle}
disabled={busy}
className="sans"
style={{ border: '1px solid var(--accent)', borderRadius: 999, padding: '11px 24px', background: 'rgba(127,153,189,0.14)', color: '#d8e2ef', fontWeight: 600, fontSize: '0.9rem', cursor: 'pointer' }}
>
{busy ? 'Saving…' : isLive ? 'Switch to Maintenance' : 'Switch to Live'}
</button>
</div>
<div className="grid-4" style={{ gap: 14, marginBottom: 28 }}>
{stats.map((s) => (
<div key={s.label} style={{ padding: 20, border: '1px solid var(--line)', borderRadius: 12, background: 'var(--panel-grad)' }}>
<div className="display" style={{ fontSize: '2rem', color: 'var(--head)', lineHeight: 1 }}>
{s.value}
</div>
<div className="card-kicker" style={{ marginTop: 8, marginBottom: 0 }}>
{s.label}
</div>
</div>
))}
</div>
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.25rem', color: 'var(--head)' }}>
Recent activity
</h2>
<div className="panel-flat">
{(dash.recent_activity || []).length === 0 && (
<div className="adm-td" style={{ borderBottom: 'none' }}>No activity yet.</div>
)}
{(dash.recent_activity || []).map((a) => (
<div
key={a.id}
className="sans"
style={{ display: 'flex', gap: 14, alignItems: 'center', padding: '13px 18px', borderBottom: '1px solid var(--line-soft)', fontSize: '0.86rem' }}
>
<span style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', width: 110, fontFamily: 'ui-monospace,Menlo,monospace' }}>
{a.action}
</span>
<span style={{ flex: 1, color: 'var(--text)' }}>{formatDetail(a)}</span>
<span className="dim" style={{ flex: 'none' }}>{ago(a.created_at)}</span>
</div>
))}
</div>
</section>
)
}
// Render the JSON `detail` column in a human-ish way.
export function formatDetail(a) {
if (!a.detail) return a.username ? `by ${a.username}` : '—'
try {
const obj = JSON.parse(a.detail)
return Object.entries(obj)
.map(([k, v]) => `${k}: ${v}`)
.join(', ')
} catch {
return a.detail
}
}

View File

@@ -0,0 +1,167 @@
import { useState } from 'react'
import Modal from '../../../components/Modal.jsx'
import { api } from '../../../api/client.js'
const CATEGORIES = [
{ v: 'news', l: 'News' },
{ v: 'five-on-friday', l: 'Five on Friday' },
{ v: 'newsletter', l: 'Newsletter' },
{ v: 'screenshots', l: 'Screenshots' },
]
const DB_TO_URL = { news: 'news', five_on_friday: 'five-on-friday', newsletter: 'newsletter', screenshot: 'screenshots' }
export default function PostEditor({ post, onClose, onSaved }) {
const isEdit = Boolean(post)
const [form, setForm] = useState({
category: post ? DB_TO_URL[post.category] || 'news' : 'news',
title: post?.title || '',
slug: post?.slug || '',
excerpt: post?.excerpt || '',
body: post?.body || '',
image_url: post?.image_url || '',
published: post ? Boolean(post.published) : false,
})
const [busy, setBusy] = useState(false)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }))
const isScreenshot = form.category === 'screenshots'
async function onUpload(e) {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
setError('')
try {
const res = await api.admin.uploadImage(file)
setForm((f) => ({ ...f, image_url: res.image_url }))
} catch (err) {
setError(err.message || 'Upload failed')
} finally {
setUploading(false)
}
}
async function save() {
if (!form.title.trim()) return setError('Title is required.')
if (isScreenshot && !form.image_url) return setError('Screenshots need an image.')
setBusy(true)
setError('')
const payload = {
category: form.category,
title: form.title.trim(),
slug: form.slug.trim() || null,
excerpt: form.excerpt.trim() || null,
body: form.body || null,
image_url: form.image_url || null,
published: form.published,
}
try {
if (isEdit) await api.admin.updatePost(post.id, payload)
else await api.admin.createPost(payload)
onSaved()
} catch (err) {
setError(err.message || 'Could not save the post.')
setBusy(false)
}
}
async function remove() {
if (!confirm('Delete this post? This cannot be undone.')) return
setBusy(true)
try {
await api.admin.deletePost(post.id)
onSaved()
} catch (err) {
setError(err.message || 'Could not delete.')
setBusy(false)
}
}
return (
<Modal
title={isEdit ? 'Edit post' : 'New post'}
onClose={onClose}
width={640}
footer={
<>
{isEdit && (
<button onClick={remove} disabled={busy} className="sans" style={delStyle}>
Delete
</button>
)}
<button onClick={onClose} disabled={busy} className="pill">
Cancel
</button>
<button onClick={save} disabled={busy || uploading} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save'}
</button>
</>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 200px' }}>
<span className="field-label">Category</span>
<select value={form.category} onChange={set('category')} className="select">
{CATEGORIES.map((c) => (
<option key={c.v} value={c.v}>
{c.l}
</option>
))}
</select>
</label>
<label style={{ display: 'flex', alignItems: 'flex-end', gap: 8, paddingBottom: 11 }}>
<input type="checkbox" checked={form.published} onChange={set('published')} />
<span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>Published</span>
</label>
</div>
<label>
<span className="field-label">Title</span>
<input type="text" value={form.title} onChange={set('title')} className="input" />
</label>
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 200px' }}>
<span className="field-label">Slug (optional)</span>
<input type="text" value={form.slug} onChange={set('slug')} className="input" placeholder="auto" />
</label>
</div>
<label>
<span className="field-label">Excerpt (optional)</span>
<input type="text" value={form.excerpt} onChange={set('excerpt')} className="input" />
</label>
<label>
<span className="field-label">Image{isScreenshot ? ' (required)' : ' (optional)'}</span>
<input type="file" accept="image/*" onChange={onUpload} className="sans" style={{ color: 'var(--muted)', fontSize: '0.85rem' }} />
{uploading && <span className="sans dim" style={{ fontSize: '0.8rem' }}> uploading</span>}
{form.image_url && (
<img src={form.image_url} alt="" style={{ display: 'block', marginTop: 10, maxWidth: '100%', borderRadius: 8, border: '1px solid var(--line)' }} />
)}
</label>
<label>
<span className="field-label">Body (HTML or text)</span>
<textarea value={form.body} onChange={set('body')} className="textarea" />
</label>
</div>
</Modal>
)
}
const delStyle = {
border: '1px solid #6e3b38',
borderRadius: 999,
padding: '7px 16px',
background: 'rgba(110,59,56,0.18)',
color: '#d98b84',
fontSize: '0.86rem',
cursor: 'pointer',
marginRight: 'auto',
}

View File

@@ -0,0 +1,108 @@
import { useCallback, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { shortDate, categoryLabel } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import PostEditor from './PostEditor.jsx'
const FILTERS = [
{ key: 'all', label: 'All' },
{ key: 'news', label: 'News', db: 'news' },
{ key: 'five_on_friday', label: 'Five on Friday', db: 'five_on_friday' },
{ key: 'newsletter', label: 'Newsletter', db: 'newsletter' },
{ key: 'screenshot', label: 'Screenshots', db: 'screenshot' },
]
export default function PostsAdmin() {
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const { loading, error, data } = useAsync(() => api.admin.listPosts(), [tick])
const [filter, setFilter] = useState('all')
const [editing, setEditing] = useState(null) // null | 'new' | post object
const posts = (data || []).filter((p) => filter === 'all' || p.category === filter)
return (
<section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 14, marginBottom: 18, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{FILTERS.map((f) => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
className="pill"
style={
filter === f.key
? { borderColor: 'var(--accent)', background: 'var(--blue)', color: 'var(--ink)' }
: undefined
}
>
{f.label}
</button>
))}
</div>
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
+ New post
</button>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load posts." />}
{!loading && !error && (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Title</th>
<th className="adm-th">Category</th>
<th className="adm-th">Status</th>
<th className="adm-th">Date</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{posts.length === 0 && (
<tr>
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
No posts in this category yet.
</td>
</tr>
)}
{posts.map((p) => (
<tr key={p.id}>
<td className="adm-td" style={{ color: 'var(--head)' }}>
{p.title}
</td>
<td className="adm-td">{categoryLabel(p.category)}</td>
<td className="adm-td">
<span className={`badge ${p.published ? 'badge-pub' : 'badge-draft'}`}>
{p.published ? 'Published' : 'Draft'}
</span>
</td>
<td className="adm-td dim">{shortDate(p.published_at || p.created_at)}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
<span className="link-accent" onClick={() => setEditing(p)}>
Edit
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editing && (
<PostEditor
post={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null)
reload()
}}
/>
)}
</section>
)
}

View File

@@ -0,0 +1,91 @@
import { useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
// Editable settings shown on this screen (key -> label + control type).
const FIELDS = [
{ key: 'site_title', label: 'Site title' },
{ key: 'homepage_teaser', label: 'Homepage teaser', long: true },
{ key: 'maintenance_message', label: 'Maintenance message', long: true },
{ key: 'status_message', label: 'Status message' },
{ key: 'contact_email', label: 'Contact email' },
]
export default function SettingsAdmin() {
const { refresh: refreshSite } = useSite()
const [values, setValues] = useState(null)
const [initial, setInitial] = useState({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
let active = true
api.admin
.getSettings()
.then((all) => {
if (!active) return
const v = {}
FIELDS.forEach((f) => (v[f.key] = all[f.key] ?? ''))
setValues(v)
setInitial(v)
})
.catch(() => active && setError('Could not load settings.'))
.finally(() => active && setLoading(false))
return () => {
active = false
}
}, [])
if (loading) return <Loading />
if (error) return <ErrorState message={error} />
const set = (k) => (e) => {
setValues((v) => ({ ...v, [k]: e.target.value }))
setSaved(false)
}
async function save() {
setBusy(true)
setError('')
try {
await api.admin.updateSettings(values)
setInitial(values)
setSaved(true)
await refreshSite()
} catch (err) {
setError(err.message || 'Could not save settings.')
} finally {
setBusy(false)
}
}
return (
<section style={{ maxWidth: 620 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
{FIELDS.map((f) => (
<label key={f.key} style={{ display: 'block' }}>
<span className="field-label">{f.label}</span>
{f.long ? (
<textarea value={values[f.key]} onChange={set(f.key)} className="textarea" style={{ minHeight: 90 }} />
) : (
<input type="text" value={values[f.key]} onChange={set(f.key)} className="input" />
)}
</label>
))}
<div style={{ display: 'flex', gap: 10, marginTop: 6, alignItems: 'center' }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save changes'}
</button>
<button onClick={() => setValues(initial)} disabled={busy} className="pill">
Reset
</button>
{saved && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>Saved.</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,102 @@
import { useState } from 'react'
import Modal from '../../../components/Modal.jsx'
import { api } from '../../../api/client.js'
export default function UserEditor({ user, onClose, onSaved }) {
const isEdit = Boolean(user)
const [form, setForm] = useState({
username: user?.username || '',
password: '',
role: user?.role || 'admin',
})
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }))
async function save() {
if (!form.username.trim()) return setError('Username is required.')
if (!isEdit && form.password.length < 8) return setError('Password must be at least 8 characters.')
if (isEdit && form.password && form.password.length < 8) return setError('Password must be at least 8 characters.')
setBusy(true)
setError('')
try {
if (isEdit) {
const payload = { username: form.username.trim(), role: form.role }
if (form.password) payload.password = form.password
await api.admin.updateUser(user.id, payload)
} else {
await api.admin.createUser({ username: form.username.trim(), password: form.password, role: form.role })
}
onSaved()
} catch (err) {
setError(err.message || 'Could not save the user.')
setBusy(false)
}
}
async function remove() {
if (!confirm(`Delete user "${user.username}"?`)) return
setBusy(true)
try {
await api.admin.deleteUser(user.id)
onSaved()
} catch (err) {
setError(err.message || 'Could not delete this user.')
setBusy(false)
}
}
return (
<Modal
title={isEdit ? `Edit ${user.username}` : 'Add user'}
onClose={onClose}
width={460}
footer={
<>
{isEdit && (
<button onClick={remove} disabled={busy} className="sans" style={delStyle}>
Delete
</button>
)}
<button onClick={onClose} disabled={busy} className="pill">
Cancel
</button>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save'}
</button>
</>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
<label>
<span className="field-label">Username</span>
<input type="text" value={form.username} onChange={set('username')} className="input" autoComplete="off" />
</label>
<label>
<span className="field-label">{isEdit ? 'New password (leave blank to keep)' : 'Password'}</span>
<input type="password" value={form.password} onChange={set('password')} className="input" autoComplete="new-password" />
</label>
<label>
<span className="field-label">Role</span>
<select value={form.role} onChange={set('role')} className="select">
<option value="admin">admin</option>
<option value="editor">editor</option>
</select>
</label>
</div>
</Modal>
)
}
const delStyle = {
border: '1px solid #6e3b38',
borderRadius: 999,
padding: '7px 16px',
background: 'rgba(110,59,56,0.18)',
color: '#d98b84',
fontSize: '0.86rem',
cursor: 'pointer',
marginRight: 'auto',
}

View File

@@ -0,0 +1,74 @@
import { useCallback, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import UserEditor from './UserEditor.jsx'
export default function UsersAdmin() {
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const { loading, error, data } = useAsync(() => api.admin.listUsers(), [tick])
const [editing, setEditing] = useState(null) // null | 'new' | user
const users = data || []
return (
<section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
Manage admin and editor accounts
</p>
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
+ Add user
</button>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load users." />}
{!loading && !error && (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Username</th>
<th className="adm-th">Role</th>
<th className="adm-th">Last login</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td className="adm-td" style={{ color: 'var(--head)' }}>
{u.username}
</td>
<td className="adm-td">
<span className={`badge ${u.role === 'admin' ? 'badge-admin' : 'badge-editor'}`}>{u.role}</span>
</td>
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
<span className="link-accent" onClick={() => setEditing(u)}>
Edit
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editing && (
<UserEditor
user={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null)
reload()
}}
/>
)}
</section>
)
}

View File

@@ -0,0 +1,74 @@
import { useCallback, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { shortDate } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import WikiEditor from './WikiEditor.jsx'
export default function WikiAdmin() {
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const { loading, error, data } = useAsync(() => api.admin.listWiki(), [tick])
const [editing, setEditing] = useState(null) // null | 'new' | slug
const pages = data || []
return (
<section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
{pages.length} page{pages.length === 1 ? '' : 's'} · edit content and structure
</p>
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
+ New page
</button>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load wiki pages." />}
{!loading && !error && (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Page</th>
<th className="adm-th">Slug</th>
<th className="adm-th">Updated</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{pages.map((w) => (
<tr key={w.slug}>
<td className="adm-td" style={{ color: 'var(--head)' }}>
{w.title}
</td>
<td className="adm-td" style={{ fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)' }}>
{w.slug}
</td>
<td className="adm-td dim">{shortDate(w.updated_at)}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
<span className="link-accent" onClick={() => setEditing(w.slug)}>
Edit
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editing && (
<WikiEditor
slug={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null)
reload()
}}
/>
)}
</section>
)
}

View File

@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react'
import Modal from '../../../components/Modal.jsx'
import { api } from '../../../api/client.js'
export default function WikiEditor({ slug, onClose, onSaved }) {
const isEdit = Boolean(slug)
const [form, setForm] = useState({ slug: '', title: '', body: '' })
const [loading, setLoading] = useState(isEdit)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!isEdit) return
let active = true
api.admin
.getWiki(slug)
.then((p) => active && setForm({ slug: p.slug, title: p.title, body: p.body || '' }))
.catch(() => active && setError('Could not load this page.'))
.finally(() => active && setLoading(false))
return () => {
active = false
}
}, [slug, isEdit])
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }))
async function save() {
if (!form.title.trim()) return setError('Title is required.')
if (!isEdit && !/^[a-z0-9-]+$/.test(form.slug)) return setError('Slug must be lowercase letters, numbers, and dashes.')
setBusy(true)
setError('')
try {
if (isEdit) await api.admin.updateWiki(slug, { title: form.title.trim(), body: form.body })
else await api.admin.createWiki({ slug: form.slug, title: form.title.trim(), body: form.body })
onSaved()
} catch (err) {
setError(err.message || 'Could not save.')
setBusy(false)
}
}
async function remove() {
if (!confirm('Delete this wiki page?')) return
setBusy(true)
try {
await api.admin.deleteWiki(slug)
onSaved()
} catch (err) {
setError(err.message || 'Could not delete.')
setBusy(false)
}
}
return (
<Modal
title={isEdit ? 'Edit wiki page' : 'New wiki page'}
onClose={onClose}
width={640}
footer={
<>
{isEdit && (
<button onClick={remove} disabled={busy} className="sans" style={delStyle}>
Delete
</button>
)}
<button onClick={onClose} disabled={busy} className="pill">
Cancel
</button>
<button onClick={save} disabled={busy || loading} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save'}
</button>
</>
}
>
{loading ? (
<span className="spin" />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
<label>
<span className="field-label">Slug</span>
<input
type="text"
value={form.slug}
onChange={set('slug')}
disabled={isEdit}
className="input"
style={{ fontFamily: 'ui-monospace,Menlo,monospace', opacity: isEdit ? 0.6 : 1 }}
placeholder="new-player-guide"
/>
</label>
<label>
<span className="field-label">Title</span>
<input type="text" value={form.title} onChange={set('title')} className="input" />
</label>
<label>
<span className="field-label">Body (HTML use &lt;h2&gt; for the table of contents)</span>
<textarea value={form.body} onChange={set('body')} className="textarea" style={{ minHeight: 260 }} />
</label>
</div>
)}
</Modal>
)
}
const delStyle = {
border: '1px solid #6e3b38',
borderRadius: 999,
padding: '7px 16px',
background: 'rgba(110,59,56,0.18)',
color: '#d98b84',
fontSize: '0.86rem',
cursor: 'pointer',
marginRight: 'auto',
}

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>
)
}

View File

@@ -0,0 +1,56 @@
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 { api } from '../../api/client.js'
const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII']
// Short blurbs for the seeded categories (the list endpoint returns title/slug only).
const BLURBS = {
'new-player-guide': 'First steps, basic survival, and early goals.',
'maps-atlas': 'Regions, towns, routes, and travel notes.',
systems: 'Shard mechanics and custom features.',
items: 'Equipment, treasures, rewards, and curiosities.',
monsters: 'Creatures, bosses, spawns, and dangers.',
crafting: 'Professions, materials, recipes, and tools.',
lore: 'Stories, places, factions, and mysteries.',
rules: 'Player conduct, shard expectations, and policies.',
}
export default function Wiki() {
const { loading, error, data } = useAsync(() => api.wiki())
const pages = data || []
return (
<PublicLayout section="wiki">
<div className="shell page-body">
<PageHeader
center
eyebrow="Knowledge base"
title="Mysticmoon Wiki"
lead="A calm starting point for shard guides, maps, systems, items, monsters, crafting, lore, and rules."
/>
{loading && <Loading />}
{error && <ErrorState message="Could not load the wiki right now." />}
{!loading && !error && pages.length === 0 && <EmptyState>No wiki pages yet.</EmptyState>}
<section className="grid-4">
{pages.map((p, i) => (
<Link key={p.slug} to={`/wiki/${p.slug}`} className="card" style={{ padding: 22 }}>
<span className="display" style={{ color: 'var(--accent)', fontSize: '1.4rem', marginBottom: 10 }}>
{ROMAN[i] || i + 1}
</span>
<h3 className="display" style={{ margin: '0 0 6px', fontSize: '1.1rem', color: 'var(--head)' }}>
{p.title}
</h3>
<p className="muted" style={{ margin: 0, fontSize: '0.92rem' }}>
{BLURBS[p.slug] || 'Open the guide →'}
</p>
</Link>
))}
</section>
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,107 @@
import { useMemo } from 'react'
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 } from '../../lib/format.js'
import { api } from '../../api/client.js'
function slugify(text) {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
// Parse the stored body HTML: assign ids to <h2> headings and collect a TOC.
function buildArticle(body) {
if (!body) return { html: '', toc: [] }
if (typeof window === 'undefined' || !window.DOMParser) return { html: body, toc: [] }
const doc = new DOMParser().parseFromString(body, 'text/html')
const toc = []
doc.querySelectorAll('h2').forEach((h, i) => {
const id = slugify(h.textContent || '') || `section-${i}`
h.id = id
toc.push({ id, label: h.textContent })
})
return { html: doc.body.innerHTML, toc }
}
export default function WikiArticle() {
const { slug } = useParams()
const { loading, error, data: page } = useAsync(() => api.wikiPage(slug), [slug])
const { html, toc } = useMemo(() => buildArticle(page?.body), [page])
return (
<PublicLayout section="wiki">
<div className="shell page-body" style={{ paddingTop: 40 }}>
{loading && <Loading />}
{error && (
<ErrorState message={error.status === 404 ? 'That wiki page could not be found.' : 'Could not load this page.'} />
)}
{page && (
<div className={toc.length ? 'wiki-grid' : ''}>
{toc.length > 0 && (
<aside
style={{
position: 'sticky',
top: 90,
border: '1px solid var(--line)',
borderRadius: 10,
padding: 20,
background: 'rgba(11,22,48,0.32)',
}}
>
<p
className="sans"
style={{ margin: '0 0 12px', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase' }}
>
On this page
</p>
<nav style={{ display: 'flex', flexDirection: 'column', gap: 9, fontFamily: 'var(--sans)', fontSize: '0.9rem' }}>
{toc.map((t) => (
<a
key={t.id}
href={`#${t.id}`}
style={{ color: 'var(--text)', textDecoration: 'none', borderLeft: '2px solid var(--line)', paddingLeft: 12 }}
>
{t.label}
</a>
))}
</nav>
</aside>
)}
<article style={!toc.length ? { maxWidth: 760, margin: '0 auto' } : undefined}>
<p className="sans" style={{ margin: '0 0 12px', display: 'flex', gap: 8, color: 'var(--dim)', fontSize: '0.82rem' }}>
<Link to="/wiki" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Wiki
</Link>
<span>/</span>
<span>{page.title}</span>
</p>
<h1 className="display" style={{ margin: 0, fontSize: 'clamp(2.2rem,5vw,3.2rem)', lineHeight: 1.05, color: 'var(--head)' }}>
{page.title}
</h1>
<p className="sans" style={{ margin: '18px 0 0', color: 'var(--dim)', fontSize: '0.78rem', letterSpacing: '0.04em' }}>
Last updated {longDate(page.updated_at) || '—'}
</p>
<div style={{ height: 1, background: 'var(--line)', margin: '30px 0' }} />
{html ? (
<div className="prose" dangerouslySetInnerHTML={{ __html: html }} />
) : (
<p className="muted">This page has no content yet.</p>
)}
<nav style={{ display: 'flex', justifyContent: 'flex-start', marginTop: 40 }}>
<Link to="/wiki" className="pill">
All wiki pages
</Link>
</nav>
</article>
</div>
)}
</div>
</PublicLayout>
)
}

425
client/src/styles/theme.css Normal file
View File

@@ -0,0 +1,425 @@
/* ===== UOMysticmoon design tokens (from the Claude Design handoff) ===== */
:root {
--bg: #0e1318;
--bg-deep: #0b0f14;
--panel-a: #192231;
--panel-b: #141a21;
--panel-flat: #11161d;
--line: #2a3544;
--line-soft: #1d2733;
--accent: #7f99bd;
--accent-bright: #cdd9e8;
--ink: #eef3f8;
--head: #e6edf6;
--text: #c4cdd8;
--muted: #aeb8c4;
--dim: #6f7d8e;
--blue: #13243c;
--mode-live: #5fb98a;
--mode-maint: #e6c26a;
--serif: Georgia, "Times New Roman", serif;
--display: Cinzel, Georgia, serif;
--sans: "Helvetica Neue", Arial, sans-serif;
--shadow-card: 0 14px 34px rgba(0, 0, 0, 0.3);
--panel-grad: linear-gradient(180deg, var(--panel-a), var(--panel-b));
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
}
body {
background: var(--bg);
color: var(--ink);
font-family: var(--serif);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
}
/* ===== Typography helpers ===== */
.display {
font-family: var(--display);
font-weight: 600;
color: var(--head);
}
.sans {
font-family: var(--sans);
}
.eyebrow {
margin: 0 0 14px;
color: var(--accent);
font-family: var(--sans);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
/* ===== Layout shells ===== */
.shell {
width: min(1080px, calc(100% - 32px));
margin: 0 auto;
}
.shell-mid {
width: min(880px, calc(100% - 32px));
margin: 0 auto;
}
.shell-narrow {
width: min(760px, calc(100% - 32px));
margin: 0 auto;
}
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.page-body {
flex: 1;
padding: 56px 0 64px;
}
/* ===== Cards / panels ===== */
.card {
display: flex;
flex-direction: column;
padding: 24px;
border: 1px solid var(--line);
border-radius: 10px;
text-decoration: none;
color: var(--ink);
background: var(--panel-grad);
box-shadow: var(--shadow-card);
transition: border-color 0.18s, transform 0.18s;
}
a.card:hover,
a.card:focus-visible {
border-color: var(--accent);
transform: translateY(-3px);
outline: none;
}
.card-kicker {
color: var(--accent);
font-family: var(--sans);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
margin-bottom: 12px;
}
.panel {
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-grad);
}
.panel-flat {
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
background: var(--panel-flat);
}
.note {
border: 1px solid var(--line);
border-left: 3px solid var(--accent);
border-radius: 8px;
background: rgba(19, 36, 60, 0.4);
padding: 18px 22px;
color: var(--muted);
}
/* ===== Grids ===== */
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.grid-4 {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
@media (max-width: 860px) {
.grid-2,
.grid-3,
.grid-4 {
grid-template-columns: 1fr;
}
}
/* ===== Pills / buttons ===== */
.pill {
border: 1px solid var(--line);
border-radius: 999px;
padding: 7px 14px;
color: var(--muted);
background: rgba(11, 22, 48, 0.5);
font-family: var(--sans);
font-size: 0.86rem;
text-decoration: none;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.pill:hover,
.pill:focus-visible {
border-color: var(--accent);
background: var(--blue);
color: var(--ink);
outline: none;
}
.btn {
border-radius: 999px;
padding: 12px 26px;
font-family: var(--sans);
font-size: 0.92rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
display: inline-block;
border: 1px solid var(--accent);
transition: background 0.16s, border-color 0.16s, transform 0.16s;
}
.btn-primary {
background: var(--accent-bright);
color: var(--bg-deep);
}
.btn-primary:hover {
background: var(--ink);
}
.btn-ghost {
border-color: rgba(200, 208, 216, 0.4);
color: var(--ink);
background: rgba(11, 22, 48, 0.45);
}
.btn-ghost:hover {
border-color: var(--accent);
background: var(--blue);
}
.btn-sq {
border-radius: 8px;
padding: 10px 18px;
font-size: 0.85rem;
}
.btn[disabled],
button[disabled] {
opacity: 0.55;
cursor: not-allowed;
}
/* ===== Forms ===== */
.input,
.textarea,
.select {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
padding: 11px 14px;
background: var(--bg);
color: var(--ink);
font-family: var(--sans);
font-size: 0.95rem;
}
.textarea {
min-height: 200px;
resize: vertical;
line-height: 1.5;
}
.input:focus,
.textarea:focus,
.select:focus {
border-color: var(--accent);
outline: none;
}
.field-label {
display: block;
margin-bottom: 7px;
color: var(--muted);
font-family: var(--sans);
font-size: 0.78rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
/* ===== Headings used in prose ===== */
.h1 {
margin: 0;
font-family: var(--display);
font-weight: 600;
font-size: clamp(2.4rem, 5.5vw, 3.6rem);
line-height: 1.04;
color: var(--head);
}
.lead {
margin: 16px 0 0;
color: var(--muted);
font-size: 1.08rem;
}
/* ===== Rich prose (wiki / newsletter body) ===== */
.prose {
color: var(--text);
font-size: 1.06rem;
}
.prose h2 {
margin: 32px 0 12px;
font-family: var(--display);
font-weight: 600;
font-size: 1.6rem;
color: var(--head);
scroll-margin-top: 90px;
}
.prose h3 {
margin: 24px 0 10px;
font-family: var(--display);
font-weight: 600;
font-size: 1.25rem;
color: var(--head);
}
.prose p {
margin: 0 0 18px;
}
.prose ul,
.prose ol {
margin: 0 0 18px;
padding-left: 22px;
}
.prose li {
margin-bottom: 8px;
}
.prose a {
color: var(--accent);
}
.prose strong {
color: #dbe2ea;
}
.prose img {
max-width: 100%;
border-radius: 8px;
border: 1px solid var(--line);
}
/* ===== Admin tables ===== */
.adm-table {
width: 100%;
border-collapse: collapse;
}
.adm-th {
text-align: left;
padding: 11px 14px;
color: var(--accent);
font-family: var(--sans);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
border-bottom: 1px solid var(--line);
}
.adm-td {
padding: 13px 14px;
color: var(--text);
font-family: var(--sans);
font-size: 0.88rem;
border-bottom: 1px solid var(--line-soft);
vertical-align: middle;
}
.badge {
border-radius: 999px;
padding: 3px 11px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
font-family: var(--sans);
white-space: nowrap;
}
.badge-pub {
background: rgba(95, 185, 138, 0.16);
color: #7fd0a4;
border: 1px solid rgba(95, 185, 138, 0.4);
}
.badge-draft {
background: rgba(127, 153, 189, 0.14);
color: #9fb0c6;
border: 1px solid var(--line);
}
.badge-admin {
background: rgba(216, 226, 239, 0.12);
color: #d8e2ef;
border: 1px solid #3a4a5e;
}
.badge-editor {
background: rgba(127, 153, 189, 0.1);
color: var(--muted);
border: 1px solid var(--line);
}
.link-accent {
color: var(--accent);
text-decoration: none;
cursor: pointer;
}
.link-accent:hover {
text-decoration: underline;
}
/* ===== Misc ===== */
.moon {
display: inline-block;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #eef3f8, #9fb0c6 55%, #5d6e88);
}
.muted {
color: var(--muted);
}
.dim {
color: var(--dim);
}
.center {
text-align: center;
}
.spin {
width: 26px;
height: 26px;
border: 2px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.admin-grid {
min-height: 100vh;
display: grid;
grid-template-columns: 236px 1fr;
}
@media (max-width: 760px) {
.admin-grid {
grid-template-columns: 1fr;
}
}
.wiki-grid {
display: grid;
grid-template-columns: 230px 1fr;
gap: 44px;
align-items: start;
}
@media (max-width: 860px) {
.wiki-grid {
grid-template-columns: 1fr;
}
}

19
client/vite.config.js Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// In dev, proxy the API + uploads to the Express server so the SPA stays
// same-origin (cookies work) and matches the production setup where Express
// serves the built client.
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:3000', changeOrigin: true },
'/uploads': { target: 'http://localhost:3000', changeOrigin: true },
},
},
build: {
outDir: 'dist',
},
})

38
server/_setup.ps1 Normal file
View File

@@ -0,0 +1,38 @@
$ErrorActionPreference = 'Stop'
$base = 'http://127.0.0.1:3000'
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
function Req($method, $path, $bodyObj) {
$p = @{ Method = $method; Uri = "$base$path"; UseBasicParsing = $true; WebSession = $session; ErrorAction = 'Stop' }
if ($null -ne $bodyObj) { $p.Body = ($bodyObj | ConvertTo-Json -Depth 6 -Compress); $p.ContentType = 'application/json' }
Invoke-WebRequest @p
}
Write-Output 'waiting for health...'
$up = $false
for ($i = 0; $i -lt 40; $i++) {
try { if ((Invoke-WebRequest "$base/api/health" -UseBasicParsing).StatusCode -eq 200) { $up = $true; break } } catch {}
Start-Sleep -Seconds 2
}
if (-not $up) { Write-Output 'server never came up'; return }
Req POST '/api/v1/auth/login' @{ username = 'admin'; password = 'adminpass123' } | Out-Null
Req PUT '/api/v1/admin/site-mode' @{ mode = 'live' } | Out-Null
# News
Req POST '/api/v1/admin/posts' @{ category='news'; title='The world map enters closed testing'; excerpt='The Mysticmoon overworld is feature-complete enough for a small group to wander.'; body='<p>Travel between the three starting towns is live, moongates are seeded, and the first dungeon level is open for stress-testing.</p>'; published=$true } | Out-Null
Req POST '/api/v1/admin/posts' @{ category='news'; title='Crafting trees and resource gathering'; excerpt='Mining, lumberjacking, and the first tier of smithing and tailoring are in.'; body='<p>Resource respawn timers are tuned for a small population.</p>'; published=$true } | Out-Null
# Five on Friday
Req POST '/api/v1/admin/posts' @{ category='five-on-friday'; title='Five on Friday #07'; body='<ol><li>Moongates now route correctly between all three regions.</li><li>The blacksmith UI got a readability pass.</li><li>Something large now lurks in the Hollow Deeps.</li><li>Next week: player housing placement rules.</li><li>The full-moon lighting in town looks lovely.</li></ol>'; published=$true } | Out-Null
# Newsletter
Req POST '/api/v1/admin/posts' @{ category='newsletter'; slug='june-2026'; title='A world you can walk across'; excerpt='The longest month of building yet - here is everything that landed.'; body='<p>June was the month Mysticmoon stopped being a set of disconnected systems and started feeling like a place.</p><h2>The overworld opens</h2><p>For the first time, testers walked the road from Mistholme to the eastern moongate.</p><h2>Crafting takes root</h2><p>Mining, lumberjacking, smithing, and tailoring all came online.</p>'; published=$true } | Out-Null
# Screenshot (uses the hero image already served at /assets)
Req POST '/api/v1/admin/posts' @{ category='screenshots'; title='Mistholme at dusk'; excerpt='The square at Mistholme, lanterns lit at dusk.'; image_url='/assets/img/uomysticmoon-main-hero.png'; published=$true } | Out-Null
# Wiki body with H2 sections so the article TOC renders
Req PUT '/api/v1/admin/wiki/new-player-guide' @{ title='New Player Guide'; body='<p>Everything you need to find your feet in your first hour on Mysticmoon.</p><h2>When you arrive</h2><p>New characters wake in <strong>Mistholme</strong>, the central starting town.</p><h2>Choosing your first skills</h2><p>You do not pick a class - you grow into one by using skills.</p><h2>Staying alive</h2><ul><li>Towns are safe. The wilderness is not.</li><li>Bank your gold and reagents often.</li><li>Keep bandages and a spare weapon.</li></ul>' } | Out-Null
Write-Output 'SETUP DONE - site is live with sample content'