Frontend update
This commit is contained in:
16
client/index.html
Normal file
16
client/index.html
Normal 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
1761
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
client/package.json
Normal file
20
client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
client/public/assets/img/uomysticmoon-main-hero.png
Normal file
BIN
client/public/assets/img/uomysticmoon-main-hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 672 KiB |
2
client/public/robots.txt
Normal file
2
client/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /admin
|
||||
80
client/src/App.jsx
Normal file
80
client/src/App.jsx
Normal 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
87
client/src/api/client.js
Normal 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 }
|
||||
22
client/src/components/MaintenanceGate.jsx
Normal file
22
client/src/components/MaintenanceGate.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import Maintenance from '../routes/public/Maintenance.jsx'
|
||||
|
||||
// Wraps the public site. When the shard is in maintenance, visitors see the
|
||||
// coming-soon page; a logged-in admin sees the real site (live preview).
|
||||
export default function MaintenanceGate({ children }) {
|
||||
const { mode, loading } = useSite()
|
||||
const { user, loading: authLoading } = useAuth()
|
||||
|
||||
if (loading || authLoading) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', background: 'var(--bg-deep)' }}>
|
||||
<span className="spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (mode === 'maintenance' && !user) {
|
||||
return <Maintenance />
|
||||
}
|
||||
return children
|
||||
}
|
||||
70
client/src/components/Modal.jsx
Normal file
70
client/src/components/Modal.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
// Simple centered modal used by the admin editors.
|
||||
export default function Modal({ title, onClose, children, footer, width = 560 }) {
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseDown={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(6,9,13,0.66)',
|
||||
backdropFilter: 'blur(3px)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
padding: 18,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="panel"
|
||||
style={{ width: '100%', maxWidth: width, maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '18px 22px',
|
||||
borderBottom: '1px solid var(--line-soft)',
|
||||
}}
|
||||
>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="sans"
|
||||
style={{ border: 'none', background: 'transparent', color: 'var(--muted)', fontSize: '1.4rem', cursor: 'pointer', lineHeight: 1 }}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: 22, overflow: 'auto' }}>{children}</div>
|
||||
{footer && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 10,
|
||||
padding: '16px 22px',
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
client/src/components/MoonDot.jsx
Normal file
9
client/src/components/MoonDot.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
// The little glowing moon used in the logo, login, and maintenance screens.
|
||||
export default function MoonDot({ size = 13, glow = 0.45 }) {
|
||||
return (
|
||||
<span
|
||||
className="moon"
|
||||
style={{ width: size, height: size, boxShadow: `0 0 ${size * 0.8}px rgba(216,226,239,${glow})` }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
14
client/src/components/PageHeader.jsx
Normal file
14
client/src/components/PageHeader.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
// The eyebrow + title + lead block at the top of most pages.
|
||||
export default function PageHeader({ eyebrow, title, lead, center = false }) {
|
||||
return (
|
||||
<section style={{ marginBottom: 40, textAlign: center ? 'center' : 'left' }}>
|
||||
{eyebrow && <p className="eyebrow">{eyebrow}</p>}
|
||||
<h1 className="h1">{title}</h1>
|
||||
{lead && (
|
||||
<p className="lead" style={{ maxWidth: 680, marginLeft: center ? 'auto' : undefined, marginRight: center ? 'auto' : undefined }}>
|
||||
{lead}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
27
client/src/components/PageState.jsx
Normal file
27
client/src/components/PageState.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
// Consistent loading / error / empty states for data-driven sections.
|
||||
export function Loading({ label = 'Loading…' }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, color: 'var(--muted)', padding: '24px 0' }}>
|
||||
<span className="spin" /> {label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorState({ message = 'Something went wrong.' }) {
|
||||
return (
|
||||
<div className="note" style={{ borderLeftColor: '#b4665f' }}>
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState({ children }) {
|
||||
return (
|
||||
<div
|
||||
className="panel"
|
||||
style={{ padding: '28px', color: 'var(--muted)', textAlign: 'center', fontFamily: 'var(--sans)', fontSize: '0.95rem' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
client/src/components/PublicLayout.jsx
Normal file
13
client/src/components/PublicLayout.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import SiteHeader from './SiteHeader.jsx'
|
||||
import SiteFooter from './SiteFooter.jsx'
|
||||
|
||||
// Standard page chrome for the public site + wiki.
|
||||
export default function PublicLayout({ section = 'website', header = true, children }) {
|
||||
return (
|
||||
<div className="page">
|
||||
{header && <SiteHeader section={section} />}
|
||||
{children}
|
||||
<SiteFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
client/src/components/RequireAuth.jsx
Normal file
20
client/src/components/RequireAuth.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
|
||||
// Gate for /admin/* — redirects to the login screen when not authenticated.
|
||||
export default function RequireAuth({ children }) {
|
||||
const { user, loading } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', background: 'var(--bg-deep)' }}>
|
||||
<span className="spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!user) {
|
||||
return <Navigate to="/admin/login" state={{ from: location }} replace />
|
||||
}
|
||||
return children
|
||||
}
|
||||
36
client/src/components/SiteFooter.jsx
Normal file
36
client/src/components/SiteFooter.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
|
||||
export default function SiteFooter() {
|
||||
const { contactEmail } = useSite()
|
||||
return (
|
||||
<footer
|
||||
className="sans"
|
||||
style={{
|
||||
borderTop: '1px solid var(--line)',
|
||||
padding: '28px 16px',
|
||||
color: 'var(--muted)',
|
||||
textAlign: 'center',
|
||||
fontSize: '0.9rem',
|
||||
background: 'rgba(9,13,18,0.6)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
||||
<span>UOMysticmoon is an independent private shard project.</span>
|
||||
<span style={{ color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
{contactEmail}
|
||||
</a>
|
||||
·
|
||||
<Link to="/site/status" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Shard Status
|
||||
</Link>
|
||||
·
|
||||
<Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}>
|
||||
Admin
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
73
client/src/components/SiteHeader.jsx
Normal file
73
client/src/components/SiteHeader.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import MoonDot from './MoonDot.jsx'
|
||||
|
||||
const NAV = {
|
||||
website: [
|
||||
{ label: 'News', to: '/site/news' },
|
||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
||||
{ label: 'About', to: '/site/about' },
|
||||
{ label: 'Wiki', to: '/wiki' },
|
||||
],
|
||||
wiki: [
|
||||
{ label: 'Website', to: '/site' },
|
||||
{ label: 'New Player Guide', to: '/wiki/new-player-guide' },
|
||||
{ label: 'Maps & Atlas', to: '/wiki/maps-atlas' },
|
||||
{ label: 'Systems', to: '/wiki/systems' },
|
||||
{ label: 'Rules', to: '/wiki/rules' },
|
||||
],
|
||||
}
|
||||
|
||||
export default function SiteHeader({ section = 'website' }) {
|
||||
const links = NAV[section] || NAV.website
|
||||
return (
|
||||
<header
|
||||
style={{
|
||||
borderBottom: '1px solid var(--line)',
|
||||
background: 'rgba(9,13,18,0.86)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 30,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="shell"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 20,
|
||||
padding: '14px 0',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
to="/"
|
||||
className="display"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
fontSize: '1.2rem',
|
||||
letterSpacing: '0.05em',
|
||||
color: 'var(--accent-bright)',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<MoonDot />
|
||||
UOMysticmoon
|
||||
</Link>
|
||||
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
{links.map((l) => (
|
||||
<Link key={l.to + l.label} to={l.to} className="pill">
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
50
client/src/contexts/AuthContext.jsx
Normal file
50
client/src/contexts/AuthContext.jsx
Normal 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
|
||||
}
|
||||
42
client/src/contexts/SiteContext.jsx
Normal file
42
client/src/contexts/SiteContext.jsx
Normal 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
66
client/src/lib/format.js
Normal 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
|
||||
}
|
||||
20
client/src/lib/useAsync.js
Normal file
20
client/src/lib/useAsync.js
Normal 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
13
client/src/main.jsx
Normal 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>,
|
||||
)
|
||||
154
client/src/routes/admin/AdminLayout.jsx
Normal file
154
client/src/routes/admin/AdminLayout.jsx
Normal 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 <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>
|
||||
)
|
||||
}
|
||||
124
client/src/routes/admin/AdminLogin.jsx
Normal file
124
client/src/routes/admin/AdminLogin.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
client/src/routes/admin/views/ActivityAdmin.jsx
Normal file
57
client/src/routes/admin/views/ActivityAdmin.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
138
client/src/routes/admin/views/Dashboard.jsx
Normal file
138
client/src/routes/admin/views/Dashboard.jsx
Normal 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
|
||||
}
|
||||
}
|
||||
167
client/src/routes/admin/views/PostEditor.jsx
Normal file
167
client/src/routes/admin/views/PostEditor.jsx
Normal 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',
|
||||
}
|
||||
108
client/src/routes/admin/views/PostsAdmin.jsx
Normal file
108
client/src/routes/admin/views/PostsAdmin.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
client/src/routes/admin/views/SettingsAdmin.jsx
Normal file
91
client/src/routes/admin/views/SettingsAdmin.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
client/src/routes/admin/views/UserEditor.jsx
Normal file
102
client/src/routes/admin/views/UserEditor.jsx
Normal 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',
|
||||
}
|
||||
74
client/src/routes/admin/views/UsersAdmin.jsx
Normal file
74
client/src/routes/admin/views/UsersAdmin.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
client/src/routes/admin/views/WikiAdmin.jsx
Normal file
74
client/src/routes/admin/views/WikiAdmin.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
client/src/routes/admin/views/WikiEditor.jsx
Normal file
115
client/src/routes/admin/views/WikiEditor.jsx
Normal 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 <h2> 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',
|
||||
}
|
||||
44
client/src/routes/public/About.jsx
Normal file
44
client/src/routes/public/About.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
export default function About() {
|
||||
const { contactEmail } = useSite()
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader eyebrow="About" title="About Mysticmoon" />
|
||||
<div className="prose">
|
||||
<p>
|
||||
Mysticmoon is an independent, privately-run Ultima Online shard built by a small group of long-time players.
|
||||
It is not affiliated with or endorsed by the owners of Ultima Online — it is a labor of love for the old
|
||||
worlds and the friendships made in them.
|
||||
</p>
|
||||
<p>
|
||||
Our aim is a calm, hand-tended world: a contested wilderness worth exploring, safe towns worth living in,
|
||||
and systems that reward curiosity over grind. We are building slowly and in the open, sharing news,
|
||||
screenshots, and guides as the world comes online.
|
||||
</p>
|
||||
<h2>What to expect</h2>
|
||||
<ul>
|
||||
<li>A hybrid ruleset — safe towns, a dangerous wild.</li>
|
||||
<li>Custom crafting, housing, and exploration content.</li>
|
||||
<li>A small, friendly population and an active wiki.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<section className="note" style={{ marginTop: 30 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 6px', fontSize: '1.15rem', color: 'var(--head)' }}>
|
||||
Get in touch
|
||||
</h3>
|
||||
<p style={{ margin: 0, color: 'var(--muted)' }}>
|
||||
Questions, ideas, or want to help build? Reach us at{' '}
|
||||
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
{contactEmail}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
49
client/src/routes/public/FiveOnFriday.jsx
Normal file
49
client/src/routes/public/FiveOnFriday.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { longDate } from '../../lib/format.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
export default function FiveOnFriday() {
|
||||
const { loading, error, data } = useAsync(() => api.posts('five-on-friday'))
|
||||
const issues = data || []
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<PageHeader
|
||||
eyebrow="Community"
|
||||
title="Five on Friday"
|
||||
lead="Five short notes from the week — what we built, what is next, and one small thing we are excited about."
|
||||
/>
|
||||
<section style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load Five on Friday right now." />}
|
||||
{!loading && !error && issues.length === 0 && (
|
||||
<EmptyState>No Five on Friday posts yet — the first one is coming soon.</EmptyState>
|
||||
)}
|
||||
{issues.map((it) => (
|
||||
<article key={it.id} className="panel" style={{ padding: 30 }}>
|
||||
<div
|
||||
className="sans"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18, fontSize: '0.74rem', letterSpacing: '0.08em', textTransform: 'uppercase' }}
|
||||
>
|
||||
<span style={{ color: 'var(--accent)', fontWeight: 700 }}>Five on Friday</span>
|
||||
<span className="dim">{longDate(it.published_at || it.created_at)}</span>
|
||||
</div>
|
||||
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.5rem', color: 'var(--head)' }}>
|
||||
{it.title}
|
||||
</h2>
|
||||
{it.body ? (
|
||||
<div className="prose" dangerouslySetInnerHTML={{ __html: it.body }} />
|
||||
) : (
|
||||
it.excerpt && <p style={{ margin: 0, color: 'var(--text)' }}>{it.excerpt}</p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
62
client/src/routes/public/Maintenance.jsx
Normal file
62
client/src/routes/public/Maintenance.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
const HERO_BG =
|
||||
"linear-gradient(180deg,rgba(11,15,20,0.55) 0%,rgba(11,15,20,0.74) 60%,rgba(11,15,20,0.92) 100%),url('/assets/img/uomysticmoon-main-hero.png')"
|
||||
|
||||
export default function Maintenance() {
|
||||
const { settings, contactEmail } = useSite()
|
||||
const message =
|
||||
settings.maintenance_message ||
|
||||
'Mysticmoon is in maintenance while we shape its towns, roads, and dungeons. The gates will open soon. Until then, follow along as the world wakes.'
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'grid',
|
||||
alignContent: 'center',
|
||||
justifyItems: 'center',
|
||||
textAlign: 'center',
|
||||
padding: '80px max(18px,calc((100% - 760px)/2))',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'var(--bg-deep)',
|
||||
backgroundImage: HERO_BG,
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'cover',
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 640, textShadow: '0 2px 22px rgba(0,0,0,0.85)' }}>
|
||||
<div style={{ marginBottom: 26 }}>
|
||||
<MoonDot size={18} glow={0.6} />
|
||||
</div>
|
||||
<p className="eyebrow" style={{ color: '#c2d2e6', letterSpacing: '0.24em' }}>
|
||||
Building beneath the moon
|
||||
</p>
|
||||
<h1
|
||||
className="display"
|
||||
style={{ margin: 0, fontSize: 'clamp(2.6rem,7vw,4.6rem)', lineHeight: 1.05, letterSpacing: '0.02em' }}
|
||||
>
|
||||
The world is not yet open
|
||||
</h1>
|
||||
<p style={{ maxWidth: 520, margin: '24px auto 0', color: '#cdd6e0', fontSize: '1.14rem' }}>{message}</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'center', marginTop: 34 }}>
|
||||
<Link to="/site/news" className="btn btn-primary">
|
||||
Read the news
|
||||
</Link>
|
||||
<a href={`mailto:${contactEmail}`} className="btn btn-ghost">
|
||||
Contact us
|
||||
</a>
|
||||
</div>
|
||||
<p className="sans" style={{ margin: '40px 0 0', color: '#7a8696', fontSize: '0.82rem' }}>
|
||||
{contactEmail} · {' '}
|
||||
<Link to="/admin/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Admin
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
51
client/src/routes/public/News.jsx
Normal file
51
client/src/routes/public/News.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { longDate } from '../../lib/format.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
export default function News() {
|
||||
const { loading, error, data } = useAsync(() => api.posts('news'))
|
||||
const posts = data || []
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<PageHeader
|
||||
eyebrow="Development"
|
||||
title="News & Updates"
|
||||
lead="Progress notes and announcements as Mysticmoon takes shape."
|
||||
/>
|
||||
<section style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load news right now." />}
|
||||
{!loading && !error && posts.length === 0 && <EmptyState>No news posts yet — check back soon.</EmptyState>}
|
||||
{posts.map((p) => (
|
||||
<article key={p.id} className="panel" style={{ padding: 28 }}>
|
||||
<div
|
||||
className="sans"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12, fontSize: '0.74rem', letterSpacing: '0.08em', textTransform: 'uppercase' }}
|
||||
>
|
||||
<span style={{ color: 'var(--accent)', fontWeight: 700 }}>News</span>
|
||||
<span className="dim">{longDate(p.published_at || p.created_at)}</span>
|
||||
</div>
|
||||
<h2 className="display" style={{ margin: '0 0 10px', fontSize: '1.55rem', color: 'var(--head)' }}>
|
||||
{p.title}
|
||||
</h2>
|
||||
{(p.excerpt || p.body) && (
|
||||
<p style={{ margin: 0, color: 'var(--text)', fontSize: '1.04rem' }}>{p.excerpt || stripHtml(p.body)}</p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function stripHtml(html) {
|
||||
if (!html) return ''
|
||||
const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
return text.length > 280 ? text.slice(0, 280) + '…' : text
|
||||
}
|
||||
136
client/src/routes/public/Newsletter.jsx
Normal file
136
client/src/routes/public/Newsletter.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { monthTile } from '../../lib/format.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
export default function Newsletter() {
|
||||
const { loading, error, data } = useAsync(() => api.posts('newsletter'))
|
||||
const issues = data || []
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<PageHeader
|
||||
eyebrow="Long-form"
|
||||
title="Monthly Newsletter"
|
||||
lead="A fuller monthly summary for players who want the whole picture."
|
||||
/>
|
||||
|
||||
<SubscribeBox />
|
||||
|
||||
<section style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load newsletter issues right now." />}
|
||||
{!loading && !error && issues.length === 0 && <EmptyState>No issues published yet.</EmptyState>}
|
||||
{issues.map((i) => {
|
||||
const tile = monthTile(i.published_at || i.created_at)
|
||||
return (
|
||||
<Link
|
||||
key={i.id}
|
||||
to={`/site/newsletter/${i.slug || i.id}`}
|
||||
className="panel"
|
||||
style={{ display: 'flex', gap: 20, alignItems: 'center', padding: '22px 24px', textDecoration: 'none' }}
|
||||
>
|
||||
<div
|
||||
className="display"
|
||||
style={{
|
||||
flex: 'none',
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--line)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(11,22,48,0.5)',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--accent)', fontSize: '0.66rem', letterSpacing: '0.1em' }}>{tile.mon}</span>
|
||||
<span style={{ color: 'var(--head)', fontSize: '1.4rem', lineHeight: 1 }}>{tile.num}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
{i.title}
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0, fontSize: '0.98rem' }}>
|
||||
{i.excerpt || ''}
|
||||
</p>
|
||||
</div>
|
||||
<span className="sans" style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.84rem' }}>
|
||||
Read →
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function SubscribeBox() {
|
||||
const { contactEmail } = useSite()
|
||||
const [email, setEmail] = useState('')
|
||||
const [status, setStatus] = useState(null) // null | 'sending' | 'done' | 'mailto' | 'error'
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!email) return
|
||||
setStatus('sending')
|
||||
try {
|
||||
const res = await api.contact({ email, message: `Newsletter subscription request from ${email}` })
|
||||
setStatus(res && res.fallback === 'mailto' ? 'mailto' : 'done')
|
||||
} catch {
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
flexWrap: 'wrap',
|
||||
padding: '22px 24px',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(19,36,60,0.4)',
|
||||
marginBottom: 34,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#dbe2ea', fontSize: '1.02rem', flex: 1, minWidth: 220 }}>
|
||||
Get each issue in your inbox the day it ships.
|
||||
</span>
|
||||
{status === 'done' && <span className="muted sans" style={{ fontSize: '0.9rem' }}>Thanks — we'll be in touch.</span>}
|
||||
{status === 'mailto' && (
|
||||
<a className="link-accent sans" href={`mailto:${contactEmail}?subject=Newsletter%20subscribe`}>
|
||||
Email us to subscribe →
|
||||
</a>
|
||||
)}
|
||||
{status !== 'done' && status !== 'mailto' && (
|
||||
<form onSubmit={onSubmit} style={{ display: 'flex', gap: 10, flex: 'none', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input"
|
||||
style={{ borderRadius: 999, minWidth: 200, width: 'auto' }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-sq" style={{ borderRadius: 999 }} disabled={status === 'sending'}>
|
||||
{status === 'sending' ? 'Sending…' : 'Subscribe'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{status === 'error' && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>Something went wrong.</span>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
61
client/src/routes/public/NewsletterIssue.jsx
Normal file
61
client/src/routes/public/NewsletterIssue.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { longDate, monthTile } from '../../lib/format.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
export default function NewsletterIssue() {
|
||||
const { id } = useParams()
|
||||
const { loading, error, data: issue } = useAsync(() => api.post('newsletter', id), [id])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
{loading && <Loading />}
|
||||
{error && (
|
||||
<ErrorState
|
||||
message={error.status === 404 ? 'That newsletter issue could not be found.' : 'Could not load this issue.'}
|
||||
/>
|
||||
)}
|
||||
{issue && <Issue issue={issue} />}
|
||||
{(error || issue) && (
|
||||
<p style={{ marginTop: 34 }}>
|
||||
<Link to="/site/newsletter" className="pill">
|
||||
← All issues
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function Issue({ issue }) {
|
||||
const tile = monthTile(issue.published_at || issue.created_at)
|
||||
const label = `${tile.mon} ${tile.num}`.trim()
|
||||
return (
|
||||
<article>
|
||||
<p className="sans" style={{ margin: '0 0 14px', display: 'flex', gap: 8, color: 'var(--dim)', fontSize: '0.82rem' }}>
|
||||
<Link to="/site/newsletter" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Newsletter
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span>{longDate(issue.published_at || issue.created_at)}</span>
|
||||
</p>
|
||||
<p className="eyebrow" style={{ letterSpacing: '0.16em' }}>
|
||||
Issue — {label}
|
||||
</p>
|
||||
<h1 className="display" style={{ margin: 0, fontSize: 'clamp(2.2rem,5vw,3.2rem)', lineHeight: 1.05, color: 'var(--head)' }}>
|
||||
{issue.title}
|
||||
</h1>
|
||||
{issue.excerpt && <p style={{ margin: '14px 0 0', color: 'var(--muted)', fontSize: '1.1rem' }}>{issue.excerpt}</p>}
|
||||
<div style={{ height: 1, background: 'var(--line)', margin: '28px 0' }} />
|
||||
{issue.body ? (
|
||||
<div className="prose" dangerouslySetInnerHTML={{ __html: issue.body }} />
|
||||
) : (
|
||||
<p className="muted">This issue has no content yet.</p>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
112
client/src/routes/public/Portal.jsx
Normal file
112
client/src/routes/public/Portal.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
const HERO_BG =
|
||||
"linear-gradient(90deg,rgba(11,15,20,0.34) 0%,rgba(11,15,20,0.5) 36%,rgba(11,15,20,0.78) 62%,rgba(11,15,20,0.66) 100%),linear-gradient(180deg,rgba(11,15,20,0.08) 0%,rgba(11,15,20,0.72) 100%),url('/assets/img/uomysticmoon-main-hero.png')"
|
||||
|
||||
const QUICK = [
|
||||
{ label: 'News', to: '/site/news' },
|
||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||
{ label: 'Monthly Newsletter', to: '/site/newsletter' },
|
||||
{ label: 'About', to: '/site/about' },
|
||||
]
|
||||
|
||||
const DESTINATIONS = [
|
||||
{
|
||||
kicker: 'Public portal',
|
||||
title: 'Mysticmoon Website',
|
||||
body: 'Updates, screenshots, newsletters, and weekly community posts from the shard.',
|
||||
to: '/site',
|
||||
},
|
||||
{
|
||||
kicker: 'Knowledge base',
|
||||
title: 'Mysticmoon Wiki',
|
||||
body: 'Guides, maps, systems, items, monsters, crafting, lore, and rules.',
|
||||
to: '/wiki',
|
||||
},
|
||||
]
|
||||
|
||||
export default function Portal() {
|
||||
const { settings } = useSite()
|
||||
const teaser =
|
||||
settings.homepage_teaser ||
|
||||
'Mysticmoon is still being shaped beneath a midnight sky — a quiet preview for the news, screenshots, guides, and community notes to come as the world wakes.'
|
||||
|
||||
return (
|
||||
<PublicLayout header={false}>
|
||||
<main style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<section
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'grid',
|
||||
alignContent: 'center',
|
||||
minHeight: 'clamp(600px,72vh,860px)',
|
||||
padding: '96px max(18px,calc((100% - 1080px)/2)) 96px',
|
||||
overflow: 'hidden',
|
||||
textAlign: 'center',
|
||||
backgroundColor: 'var(--bg-deep)',
|
||||
backgroundImage: HERO_BG,
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'cover',
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 760, margin: '0 auto', textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
|
||||
<p className="eyebrow" style={{ color: '#c2d2e6', letterSpacing: '0.22em' }}>
|
||||
Private shard project
|
||||
</p>
|
||||
<h1
|
||||
className="display"
|
||||
style={{ margin: 0, fontSize: 'clamp(3rem,8.5vw,5.75rem)', lineHeight: 1, letterSpacing: '0.02em' }}
|
||||
>
|
||||
UOMysticmoon
|
||||
</h1>
|
||||
<p style={{ margin: '22px auto 0', color: '#dbe2ea', fontSize: '1.32rem', fontStyle: 'italic' }}>
|
||||
A private Ultima Online world in progress
|
||||
</p>
|
||||
<p style={{ maxWidth: 600, margin: '22px auto 0', color: '#c4cdd8', fontSize: '1.06rem' }}>{teaser}</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'center', marginTop: 34 }}>
|
||||
<Link to="/site" className="btn btn-primary">
|
||||
Enter the Website
|
||||
</Link>
|
||||
<Link to="/wiki" className="btn btn-ghost">
|
||||
Open the Wiki
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="shell" style={{ padding: '56px 0 12px' }}>
|
||||
<nav className="grid-2" aria-label="Main destinations">
|
||||
{DESTINATIONS.map((d) => (
|
||||
<Link key={d.to} to={d.to} className="card" style={{ padding: 30 }}>
|
||||
<span className="card-kicker" style={{ letterSpacing: '0.16em', marginBottom: 14 }}>
|
||||
{d.kicker}
|
||||
</span>
|
||||
<strong
|
||||
className="display"
|
||||
style={{ fontSize: '1.7rem', color: 'var(--head)', marginBottom: 10, fontWeight: 600 }}
|
||||
>
|
||||
{d.title}
|
||||
</strong>
|
||||
<span className="muted">{d.body}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="shell" style={{ padding: '24px 0 64px' }}>
|
||||
<nav style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 10 }} aria-label="Quick links">
|
||||
{QUICK.map((q) => (
|
||||
<Link key={q.to} to={q.to} className="pill">
|
||||
{q.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</main>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
62
client/src/routes/public/Screenshots.jsx
Normal file
62
client/src/routes/public/Screenshots.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
const HATCH = 'repeating-linear-gradient(135deg,#141a21,#141a21 13px,#181f29 13px,#181f29 26px)'
|
||||
|
||||
export default function Screenshots() {
|
||||
const { loading, error, data } = useAsync(() => api.posts('screenshots'))
|
||||
const shots = data || []
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell page-body">
|
||||
<PageHeader
|
||||
eyebrow="Gallery"
|
||||
title="Gameplay Pictures"
|
||||
lead="Glimpses of towns, dungeons, events, and daily life on the shard."
|
||||
/>
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the gallery right now." />}
|
||||
{!loading && !error && shots.length === 0 && <EmptyState>No screenshots posted yet.</EmptyState>}
|
||||
<section className="grid-3">
|
||||
{shots.map((s) => (
|
||||
<figure key={s.id} className="panel-flat" style={{ margin: 0, boxShadow: 'var(--shadow-card)' }}>
|
||||
{s.image_url ? (
|
||||
<img
|
||||
src={s.image_url}
|
||||
alt={s.title || ''}
|
||||
style={{ display: 'block', width: '100%', aspectRatio: '16 / 10', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
aspectRatio: '16 / 10',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
padding: 12,
|
||||
background: HATCH,
|
||||
color: 'var(--dim)',
|
||||
fontFamily: 'ui-monospace,Menlo,monospace',
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
image · {s.slug || s.id}
|
||||
</div>
|
||||
)}
|
||||
{(s.excerpt || s.title) && (
|
||||
<figcaption
|
||||
style={{ padding: '14px 16px', color: 'var(--text)', fontSize: '0.96rem', borderTop: '1px solid var(--line)' }}
|
||||
>
|
||||
{s.excerpt || s.title}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
86
client/src/routes/public/Status.jsx
Normal file
86
client/src/routes/public/Status.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
export default function Status() {
|
||||
const { loading, error, data } = useAsync(() => api.status())
|
||||
const mode = data?.mode || 'live'
|
||||
const statusMessage = data?.status_message || ''
|
||||
const isLive = mode === 'live'
|
||||
|
||||
const stats = [
|
||||
{ value: isLive ? 'Live' : 'Maint.', label: 'Site mode' },
|
||||
{ value: isLive ? 'Open' : 'Closed', label: 'Public login' },
|
||||
{ value: statusMessage || '—', label: 'Latest note' },
|
||||
]
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader eyebrow="Live" title="Shard Status" />
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load status right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<section
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
padding: '24px 26px',
|
||||
border: `1px solid ${isLive ? 'rgba(95,185,138,0.45)' : '#5a4a2a'}`,
|
||||
borderRadius: 10,
|
||||
background: isLive
|
||||
? 'linear-gradient(180deg,rgba(22,46,34,0.5),rgba(16,26,20,0.4))'
|
||||
: 'linear-gradient(180deg,rgba(58,46,22,0.5),rgba(30,26,16,0.4))',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flex: 'none',
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
background: isLive ? 'var(--mode-live)' : 'var(--mode-maint)',
|
||||
boxShadow: `0 0 12px ${isLive ? 'rgba(95,185,138,0.7)' : 'rgba(230,194,106,0.7)'}`,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<strong
|
||||
className="display"
|
||||
style={{ display: 'block', fontSize: '1.2rem', color: isLive ? '#bfe6cf' : '#f0e3c4' }}
|
||||
>
|
||||
{isLive ? 'Live — the gates are open' : 'Maintenance — building in progress'}
|
||||
</strong>
|
||||
<span style={{ color: isLive ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
|
||||
{statusMessage || (isLive ? 'The shard is online.' : 'The gates are closed while we shape the world. Public login is not open yet.')}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid-3" style={{ gap: 14, marginBottom: 30 }}>
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="panel" style={{ padding: 20, textAlign: 'center' }}>
|
||||
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>
|
||||
{s.value}
|
||||
</div>
|
||||
<div
|
||||
className="sans"
|
||||
style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 6 }}
|
||||
>
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
40
client/src/routes/public/Website.jsx
Normal file
40
client/src/routes/public/Website.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
|
||||
const CARDS = [
|
||||
{ kicker: 'Gallery', title: 'Gameplay Pictures', body: 'Screenshots from towns, dungeons, events, and daily life on the shard.', to: '/site/screenshots' },
|
||||
{ kicker: 'Updates', title: 'Development News', body: 'Progress notes, shard milestones, and public announcements.', to: '/site/news' },
|
||||
{ kicker: 'Community', title: 'Five on Friday', body: 'Weekly questions, small previews, and notes from the team.', to: '/site/five-on-friday' },
|
||||
{ kicker: 'Long-form', title: 'Monthly Newsletter', body: 'Fuller summaries for players who want the whole picture.', to: '/site/newsletter' },
|
||||
{ kicker: 'Reference', title: 'Wiki', body: 'Guides and reference pages for the Mysticmoon world.', to: '/wiki' },
|
||||
{ kicker: 'Live', title: 'Shard Status', body: 'Launch state, test windows, and known issues.', to: '/site/status' },
|
||||
]
|
||||
|
||||
export default function Website() {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell page-body">
|
||||
<PageHeader
|
||||
center
|
||||
eyebrow="Public portal"
|
||||
title="Mysticmoon Website"
|
||||
lead="A home for gameplay pictures, development updates, community posts, monthly newsletters, and weekly Five on Friday notes."
|
||||
/>
|
||||
<section className="grid-3">
|
||||
{CARDS.map((c) => (
|
||||
<Link key={c.to + c.title} to={c.to} className="card">
|
||||
<span className="card-kicker">{c.kicker}</span>
|
||||
<h3 className="display" style={{ margin: '0 0 8px', fontSize: '1.25rem', color: 'var(--head)' }}>
|
||||
{c.title}
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0, fontSize: '0.98rem' }}>
|
||||
{c.body}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
56
client/src/routes/wiki/Wiki.jsx
Normal file
56
client/src/routes/wiki/Wiki.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
107
client/src/routes/wiki/WikiArticle.jsx
Normal file
107
client/src/routes/wiki/WikiArticle.jsx
Normal 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
425
client/src/styles/theme.css
Normal 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
19
client/vite.config.js
Normal 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',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user