Frontend update
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user