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

77 lines
2.4 KiB
TypeScript

import { type ScoreboardState } from '@sbt/shared';
import { uploadImage } from '../api';
import { Button, Field, Input } from './ui';
/** Match-wide controls: side swap, best-of, center callout, logo, theme colors. */
export function MatchControls({
state,
onChange,
}: {
state: ScoreboardState;
onChange: (next: ScoreboardState) => void;
}) {
const set = (patch: Partial<ScoreboardState>) => onChange({ ...state, ...patch });
const onLogo = async (file?: File) => {
if (!file) return;
const url = await uploadImage(file);
set({ eventLogoUrl: url });
};
return (
<div className="flex flex-col gap-3 rounded-lg border border-edge bg-panel p-4">
<div className="text-xs font-bold uppercase tracking-widest text-slate-500">Match</div>
<Button variant="primary" onClick={() => set({ swapped: !state.swapped })}>
Swap player sides
</Button>
<Field label="Center callout">
<Input
value={state.centerCallout}
placeholder="e.g. WINNERS FINALS"
onChange={(e) => set({ centerCallout: e.target.value })}
/>
</Field>
<Field label="Best of">
<Input
type="number"
min={0}
max={99}
value={state.bestOf}
onChange={(e) => set({ bestOf: Number(e.target.value) || 0 })}
/>
</Field>
<Field label="Event logo">
<div className="flex items-center gap-2">
<input type="file" accept="image/*" onChange={(e) => onLogo(e.target.files?.[0])} className="text-xs" />
{state.eventLogoUrl && (
<Button variant="ghost" onClick={() => set({ eventLogoUrl: '' })}>Clear</Button>
)}
</div>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Accent">
<input
type="color"
value={state.theme.accent}
onChange={(e) => set({ theme: { ...state.theme, accent: e.target.value } })}
className="h-9 w-full rounded border border-edge bg-ink"
/>
</Field>
<Field label="Background">
<input
type="color"
value={state.theme.bg}
onChange={(e) => set({ theme: { ...state.theme, bg: e.target.value } })}
className="h-9 w-full rounded border border-edge bg-ink"
/>
</Field>
</div>
</div>
);
}