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' import { useAuth } from '../../../contexts/AuthContext.jsx' export default function Dashboard() { const { refresh: refreshSite } = useSite() const { user } = useAuth() // PUT /admin/site-mode is adminOnly. The dashboard itself is staff-wide, so the // toggle needs its own gate — same rule the sidebar follows (AdminLayout: never // show a non-admin a control that would 403). const isAdmin = user?.role === 'admin' 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) const [modeError, setModeError] = useState('') if (loading) return if (error) return 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' }, ] // The rejection was previously unhandled: a refused toggle surfaced only as an // unhandled promise rejection in the console while the button silently reverted. async function toggle() { setBusy(true) setModeError('') try { await api.admin.setSiteMode(isLive ? 'maintenance' : 'live') await refreshSite() reload() } catch (err) { setModeError( err.status === 403 ? 'Only an administrator can change the site mode.' : 'Could not change the site mode. Try again.', ) } finally { setBusy(false) } } const changed = dash.last_change || {} let modeLabel = isLive ? 'Switch to Maintenance' : 'Switch to Live' if (busy) modeLabel = 'Saving…' return (
{/* Operator warnings: things that are quietly not working and would otherwise be discovered by someone not receiving an email. The list is normally empty, which is why it sits above the fold rather than in a panel — see ENGAGEMENT.md §1.2a (G22). */} {(dash.warnings || []).map((w) => (
{w.message} {w.href && ( <> {' '} Open settings )}
))}
Site mode
{mode}
{changed.by ? `Changed by ${changed.by}` : 'No changes recorded'} {changed.at ? ` · ${dateTime(changed.at)}` : ''}
{modeError && (
{modeError}
)}
{isAdmin && ( )}
{stats.map((s) => (
{s.value}
{s.label}
))}

Recent activity

{(dash.recent_activity || []).length === 0 && (
No activity yet.
)} {(dash.recent_activity || []).map((a) => (
{a.action} {formatDetail(a)} {ago(a.created_at)}
))}
) } // 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 } }