Files
website/client/src/routes/public/ShardActivity.jsx
Claude 49d0c1bd11 Add shard activity feed + admin live feed; fix public-feed leak
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
2026-07-11 03:10:03 -05:00

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>
)
}