Frontend update

This commit is contained in:
2026-06-26 21:51:27 -05:00
parent eef79e2403
commit 6dba6a017c
48 changed files with 5004 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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