Front ends for the rest of the sidecar data, plus a security fix the live data surfaced. - lib/shardEvents.js: shared describe()/category/label for every event kind (sales, deaths & PvP, skills, fame/karma, quests, world, and staff kinds). - Public /site/shard/activity (ShardActivity): the full event log with category filter tabs and a live tail (history + SSE merged, de-duped). Linked from the Shard page. Shard page now reuses the shared describe(). - Admin: a "Live feed (all events)" panel on the Shard admin page subscribing to the admin SSE channel — shows every kind incl. audit/cheat/login attempts. useShardFeed generalized to take a stream url; api.adminShardStreamUrl added. Security fix: GET /public/shard/feed now restricts to the public-safe kind allowlist (shardEvents.list gains a `kinds` IN-filter). Previously it returned whatever was logged — including audit.* / cheat.* / link.request. Those are still stored for the admin channel but never served publicly (verified: a public request for audit.command returns 0 rows). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
82 lines
3.6 KiB
JavaScript
82 lines
3.6 KiB
JavaScript
import { useMemo, useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
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 { useShardFeed } from '../../lib/useShardFeed.js'
|
|
import { describe, categoryOf, kindLabel, CATEGORIES } from '../../lib/shardEvents.js'
|
|
import { ago } from '../../lib/format.js'
|
|
import { api } from '../../api/client.js'
|
|
|
|
// Public activity feed: the full shard event log, filterable by category, with a
|
|
// live tail that prepends new events as they happen.
|
|
export default function ShardActivity() {
|
|
const { loading, error, data } = useAsync(() => api.shard.feed({ limit: 150 }))
|
|
const { events: live } = useShardFeed({ max: 60 })
|
|
const [cat, setCat] = useState('all')
|
|
|
|
// Merge the live tail with the loaded history, de-duped by kind+t, newest first.
|
|
const merged = useMemo(() => {
|
|
const seen = new Set()
|
|
const out = []
|
|
for (const e of [...live, ...(data || [])]) {
|
|
const key = `${e.kind}-${e.t}`
|
|
if (seen.has(key)) continue
|
|
seen.add(key)
|
|
out.push(e)
|
|
}
|
|
return out.sort((a, b) => (b.t || 0) - (a.t || 0))
|
|
}, [live, data])
|
|
|
|
const filtered = cat === 'all' ? merged : merged.filter((e) => categoryOf(e.kind) === cat)
|
|
|
|
return (
|
|
<PublicLayout section="website">
|
|
<div className="shell-narrow page-body">
|
|
<PageHeader eyebrow="Live" title="Shard Activity" />
|
|
<p style={{ marginTop: -8, marginBottom: 18 }}>
|
|
<Link to="/site/shard" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>← Back to shard</Link>
|
|
</p>
|
|
|
|
{/* Category tabs */}
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 18 }}>
|
|
{CATEGORIES.map((c) => (
|
|
<button
|
|
key={c.id}
|
|
onClick={() => setCat(c.id)}
|
|
className="pill"
|
|
style={cat === c.id ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : undefined}
|
|
>
|
|
{c.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{loading && <Loading />}
|
|
{error && <ErrorState message="Could not load the activity feed right now." />}
|
|
|
|
{!loading && !error && (
|
|
filtered.length === 0 ? (
|
|
<div className="panel" style={{ padding: 22 }}>
|
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.9rem' }}>Nothing here yet — events will appear as they happen in the world.</p>
|
|
</div>
|
|
) : (
|
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{filtered.map((e) => (
|
|
<li key={e._id || `${e.kind}-${e.t}`} className="panel" style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
<span className="sans" style={{ flex: 'none', fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>
|
|
{kindLabel(e.kind)}
|
|
</span>
|
|
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', fontSize: '0.92rem' }}>{describe(e)}</span>
|
|
<span className="sans dim" style={{ flex: 'none', fontSize: '0.76rem' }}>{ago(e.t)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
)}
|
|
</div>
|
|
</PublicLayout>
|
|
)
|
|
}
|