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