scoreboardtools/apps/web/src/components/Workspace.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

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