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>
53 lines
1.9 KiB
TypeScript
53 lines
1.9 KiB
TypeScript
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>
|
||
);
|
||
}
|