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