scoreboardtools/apps/web/src/components/ScoreBug.tsx
Ashraf Shafiq 9169bea79f Scaffold real-time SSBU scoreboard PWA
pnpm monorepo with three workspaces:
- @sbt/shared: zod ScoreboardState + WebSocket protocol (single source of truth)
- @sbt/server: Fastify REST + raw ws WebSocket + Drizzle/Postgres, run via tsx
- @sbt/web: React + Vite + Tailwind installable PWA

Real-time core: the WebSocket server holds authoritative per-board state in
memory, broadcasts to all clients (editors + OBS overlays) instantly, and
debounces Postgres saves (~750ms). One useScoreboardSync hook powers the editor,
the no-login co-edit control page, and the read-only OBS overlay.

Includes email+password auth (JWT cookie), scoreboard CRUD, logo upload,
customizable scorebug (characters/stocks/score/subtitles/callout/side-swap/theme),
Docker Compose + Caddy/nginx deploy configs, and docs/PLAN.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:09:27 -04:00

53 lines
1.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { fighterInitials, type Player, type ScoreboardState } from '@sbt/shared';
/**
* The rendered scorebug. Pure function of state — used in the editor preview, the
* control panel preview, and the OBS overlay so they always look identical.
*/
export function ScoreBug({ state }: { state: ScoreboardState }) {
const [left, right] = state.swapped
? [state.players[1], state.players[0]]
: [state.players[0], state.players[1]];
const style = {
'--accent': state.theme.accent,
'--bg': state.theme.bg,
} as React.CSSProperties;
return (
<div className="scorebug" style={style}>
<PlayerSide player={left} side="left" />
<div className="scorebug-center">
{state.eventLogoUrl && <img className="scorebug-logo" src={state.eventLogoUrl} alt="" />}
<div className="scorebug-scores">
<span>{left.score}</span>
<span className="dash"></span>
<span>{right.score}</span>
</div>
{state.centerCallout && <div className="scorebug-callout">{state.centerCallout}</div>}
{state.bestOf > 0 && <div className="scorebug-bo">Best of {state.bestOf}</div>}
</div>
<PlayerSide player={right} side="right" />
</div>
);
}
function PlayerSide({ player, side }: { player: Player; side: 'left' | 'right' }) {
return (
<div className={`scorebug-side ${side}`}>
<div className="scorebug-portrait" title={player.character || 'No character'}>
{fighterInitials(player.character)}
</div>
<div className="scorebug-names">
<span className="scorebug-name">{player.name || 'Player'}</span>
{player.subtitle && <span className="scorebug-subtitle">{player.subtitle}</span>}
<div className="scorebug-stocks">
{Array.from({ length: player.stocks }).map((_, i) => (
<span key={i} className="scorebug-stock" />
))}
</div>
</div>
</div>
);
}