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>
77 lines
2.4 KiB
TypeScript
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>
|
|
);
|
|
}
|