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>
63 lines
2.2 KiB
TypeScript
63 lines
2.2 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { FIGHTERS, fighterInitials } from '@sbt/shared';
|
|
|
|
/**
|
|
* Placeholder character picker — renders initial chips for the full SSBU roster.
|
|
* Swap the chip for a real stock icon later by mapping the fighter id to an image.
|
|
*/
|
|
export function CharacterPicker({ value, onChange }: { value: string; onChange: (id: string) => void }) {
|
|
const [open, setOpen] = useState(false);
|
|
const [q, setQ] = useState('');
|
|
|
|
const filtered = useMemo(
|
|
() => FIGHTERS.filter((f) => f.toLowerCase().includes(q.toLowerCase())),
|
|
[q],
|
|
);
|
|
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((o) => !o)}
|
|
className="flex w-full items-center gap-2 rounded-md border border-edge bg-ink px-2 py-1.5 text-sm"
|
|
>
|
|
<span className="grid h-7 w-7 place-items-center rounded bg-red-500 text-xs font-bold">
|
|
{fighterInitials(value)}
|
|
</span>
|
|
<span className="truncate">{value || 'Pick character'}</span>
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute z-20 mt-1 w-72 rounded-md border border-edge bg-panel p-2 shadow-xl">
|
|
<input
|
|
autoFocus
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
placeholder="Search fighters…"
|
|
className="mb-2 w-full rounded border border-edge bg-ink px-2 py-1 text-sm outline-none"
|
|
/>
|
|
<div className="grid max-h-64 grid-cols-5 gap-1 overflow-y-auto">
|
|
<Chip label="None" id="" active={value === ''} onPick={(id) => { onChange(id); setOpen(false); }} />
|
|
{filtered.map((f) => (
|
|
<Chip key={f} label={f} id={f} active={value === f} onPick={(id) => { onChange(id); setOpen(false); }} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Chip({ label, id, active, onPick }: { label: string; id: string; active: boolean; onPick: (id: string) => void }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
title={label}
|
|
onClick={() => onPick(id)}
|
|
className={`grid h-11 place-items-center rounded text-[11px] font-bold ${active ? 'bg-red-500 text-white' : 'bg-ink hover:bg-edge'}`}
|
|
>
|
|
{id ? fighterInitials(id) : '∅'}
|
|
</button>
|
|
);
|
|
}
|