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>
50 lines
1.6 KiB
TypeScript
50 lines
1.6 KiB
TypeScript
import { setPlayer, type Player, type ScoreboardState } from '@sbt/shared';
|
|
import { ScoreBug } from './ScoreBug';
|
|
import { PlayerPanel } from './PlayerPanel';
|
|
import { MatchControls } from './MatchControls';
|
|
|
|
/**
|
|
* Live preview + full editing controls. Shared by the owner Editor and the no-login
|
|
* Control page — both just provide `state` and an `onChange` that pushes over WebSocket.
|
|
*/
|
|
export function Workspace({
|
|
state,
|
|
onChange,
|
|
connected,
|
|
}: {
|
|
state: ScoreboardState;
|
|
onChange: (next: ScoreboardState) => void;
|
|
connected: boolean;
|
|
}) {
|
|
const patchPlayer = (index: 0 | 1, patch: Partial<Player>) =>
|
|
onChange(setPlayer(state, index, patch));
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<ConnectionDot connected={connected} />
|
|
<div className="w-full overflow-x-auto py-2">
|
|
<div className="mx-auto w-fit">
|
|
<ScoreBug state={state} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 lg:grid-cols-3">
|
|
<PlayerPanel index={0} player={state.players[0]} onChange={(p) => patchPlayer(0, p)} />
|
|
<MatchControls state={state} onChange={onChange} />
|
|
<PlayerPanel index={1} player={state.players[1]} onChange={(p) => patchPlayer(1, p)} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ConnectionDot({ connected }: { connected: boolean }) {
|
|
return (
|
|
<span className="flex items-center gap-2 text-xs text-slate-400">
|
|
<span className={`h-2 w-2 rounded-full ${connected ? 'bg-green-400' : 'bg-amber-400'}`} />
|
|
{connected ? 'Live — changes sync instantly' : 'Reconnecting…'}
|
|
</span>
|
|
);
|
|
}
|