Files
website/client/src/components/CharacterSheet.jsx
Claude c31553aeb6
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m20s
PR Checks / client-build (pull_request) Successful in 9m49s
PR Checks / bot-install (pull_request) Successful in 9m33s
feat(shard): admin write plane, help-page queue, and public champion board
Wire up the three uo-link sidecar surfaces that weren't integrated yet.

Champion spawns
- Ingest champ.update/champ.remove into a new shard_champs table (served from
  our own store, like online/houses); public /site/champs board with a nav link,
  live via the existing SSE feed (champ.* added to the public allowlist).

Staff write plane (admin + moderator)
- kick / ban / unban / broadcast via /admin/shard/*; actor is stamped server-side
  from the session, never the browser. Sidecar status codes mapped (403 disabled/
  protected, 404 unknown, 503/504 transient). admin.audit events are logged and
  surfaced at /admin/shard/audit.
- New admin "In-Game Ops" view (/admin/shard-ops), plus per-account Kick/Ban/Unban
  on the user-detail and character views (ShardAccountActions, self-gated to staff).

Help-page (support) queue
- Ingest page.new/updated/closed into a new shard_pages table; respond/close via
  /admin/shard/pages/*. Champ board and page queue are snapshotted from the
  sidecar's /champs and /pages on every WS (re)connect (guarded so a failed call
  never wipes local state).

Verified live end-to-end against MariaDB + the Rust sidecar + ServUO; unit tests
cover ingest routing (shardIngest.champsPages.test.js). Swagger regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 13:16:10 -05:00

156 lines
7.9 KiB
JavaScript

// Reusable character-sheet renderer for the char.profile shape returned by
// /public/shard/char/:serial. Presentational only — the parent handles loading
// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles).
//
// `moderation` opts in the in-game kick/ban controls for the character's account;
// they self-gate to staff (ShardAccountActions), so passing it from a page a
// player can reach is safe.
import ShardAccountActions from './ShardAccountActions.jsx'
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
function StatTile({ value, label }) {
return (
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
<div className="display" style={{ fontSize: '1.35rem', color: 'var(--head)' }}>{value}</div>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 4 }}>{label}</div>
</div>
)
}
function Vital({ label, cur, max }) {
const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0
return (
<div className="panel" style={{ padding: '12px 14px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
<span className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>{label}</span>
<span className="display" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>{cur ?? '—'}<span className="dim" style={{ fontSize: '0.8rem' }}> / {max ?? ''}</span></span>
</div>
<div style={{ height: 6, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
)
}
export default function CharacterSheet({ char, moderation = false }) {
if (!char) return null
const stats = char.stats || {}
const resist = stats.resist || {}
// Skills the character actually has, best first.
const skills = (char.skills || [])
.filter((s) => (s.value || s.base || 0) > 0)
.sort((a, b) => (b.value || 0) - (a.value || 0))
const equipment = char.equipment || []
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
{/* Identity */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>{char.name || 'Unknown'}</h2>
{char.title && <span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>{char.title}</span>}
<span
className="sans"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 999,
border: '1px solid var(--line)', fontSize: '0.74rem',
color: char.online ? '#7fd0a4' : 'var(--muted)',
}}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: char.online ? '#7fd0a4' : 'var(--dim)' }} />
{char.online ? 'Online' : 'Offline'}
</span>
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
</div>
{/* Staff moderation for this character's account (self-gates to staff). */}
{moderation && char.acct && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
<span className="sans dim" style={{ fontSize: '0.76rem' }}>Account <strong style={{ color: 'var(--ink)' }}>{char.acct}</strong></span>
<ShardAccountActions account={char.acct} />
</div>
)}
{/* Core stats */}
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>
<div className="grid-3" style={{ gap: 12 }}>
<StatTile value={stats.str ?? '—'} label="Strength" />
<StatTile value={stats.dex ?? '—'} label="Dexterity" />
<StatTile value={stats.int ?? '—'} label="Intelligence" />
</div>
<div className="grid-3" style={{ gap: 12, marginTop: 12 }}>
<Vital label="Hits" cur={stats.hits} max={stats.hitsMax} />
<Vital label="Mana" cur={stats.mana} max={stats.manaMax} />
<Vital label="Stamina" cur={stats.stam} max={stats.stamMax} />
</div>
</section>
{/* Resistances */}
{Object.keys(resist).length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Resistances</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => (
<div key={k} className="panel" style={{ padding: '10px 16px', textAlign: 'center', minWidth: 84 }}>
<div className="display" style={{ color: 'var(--head)', fontSize: '1.1rem' }}>{resist[k] ?? 0}</div>
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.66rem', textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: 2 }}>{RESIST_LABELS[k]}</div>
</div>
))}
</div>
</section>
)}
{/* Skills */}
{skills.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Skills <span className="dim">({skills.length})</span></div>
<div className="grid-2" style={{ gap: '8px 18px' }}>
{skills.map((s) => {
const cap = s.cap || 100
const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100))
return (
<div key={s.n}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 }}>
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>{s.n}</span>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem' }}>{s.value}</span>
</div>
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
)
})}
</div>
</section>
)}
{/* Equipment */}
{equipment.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{equipment.map((it) => (
<div key={it.serial} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: 5, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.05)' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{it.layer || 'Item'}</div>
<div className="sans dim" style={{ fontSize: '0.74rem' }}>id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}</div>
</div>
{it.mods && Object.keys(it.mods).length > 0 && (
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
{Object.entries(it.mods).map(([k, v]) => (
<span key={k} className="pill" style={{ fontSize: '0.7rem', padding: '2px 8px' }}>{k} {v}</span>
))}
</div>
)}
</div>
))}
</div>
</section>
)}
</div>
)
}