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

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