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>
This commit is contained in:
parent
4a7b354303
commit
9169bea79f
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
|
uploads
|
||||||
|
data
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
pnpm-debug.log*
|
||||||
|
uploads/
|
||||||
|
data/
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
coverage/
|
||||||
45
CLAUDE.md
Normal file
45
CLAUDE.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Guidance for working in this repo. Read `docs/PLAN.md` for the full design.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
Self-hosted real-time SSBU scoreboard PWA. pnpm monorepo:
|
||||||
|
|
||||||
|
- `packages/shared` — `@sbt/shared`. zod schema + types for `ScoreboardState` and the
|
||||||
|
WebSocket protocol. **Single source of truth imported by both web and server** — change
|
||||||
|
state shape here and both sides follow. No build step (consumed as TS source).
|
||||||
|
- `apps/server` — `@sbt/server`. Fastify + raw `ws`, Drizzle + Postgres. Runs via `tsx`
|
||||||
|
(no compile step in dev or prod).
|
||||||
|
- `apps/web` — `@sbt/web`. React + Vite + Tailwind PWA.
|
||||||
|
|
||||||
|
## Real-time architecture (don't break this)
|
||||||
|
|
||||||
|
`apps/server/src/rooms.ts` is the authority: in-memory state per board, broadcast to all
|
||||||
|
sockets on `update`, debounced Postgres save. `apps/server/src/ws.ts` resolves role + board
|
||||||
|
from the connect query (`overlay=`/`control=`/`board=`). The web side has exactly one sync
|
||||||
|
primitive — `apps/web/src/hooks/useScoreboardSync.ts` — reused by Editor, Control, Overlay.
|
||||||
|
|
||||||
|
When adding scorebug features: extend `ScoreboardStateSchema` in `packages/shared/src/state.ts`,
|
||||||
|
then render in `apps/web/src/components/ScoreBug.tsx` and add controls in `PlayerPanel`/
|
||||||
|
`MatchControls`. Sync, persistence, and overlay come for free.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev # web :5173 + server :3000 (Vite proxies /api and /ws)
|
||||||
|
pnpm test # vitest (shared)
|
||||||
|
pnpm typecheck
|
||||||
|
docker compose up -d --build # production-style: Postgres + server
|
||||||
|
```
|
||||||
|
|
||||||
|
Server env: `DATABASE_URL`, `JWT_SECRET`, `COOKIE_SECURE`, `UPLOAD_DIR`, `WEB_DIR`, `PORT`
|
||||||
|
(see `apps/server/src/env.ts`).
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Keep modules small and single-purpose; match the existing terse style.
|
||||||
|
- Validate all external input with zod (REST bodies and WS messages already do).
|
||||||
|
- Owner REST routes go through `requireAuth`; public flows are token-only via WebSocket.
|
||||||
|
- The overlay route must stay uncached (see `navigateFallbackDenylist` in `vite.config.ts`).
|
||||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Single image: builds the web app, then runs the server which serves the API,
|
||||||
|
# the WebSocket, the uploaded logos, and the built web app.
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install deps (cached on lockfile/manifests)
|
||||||
|
COPY pnpm-workspace.yaml package.json tsconfig.base.json ./
|
||||||
|
COPY packages/shared/package.json packages/shared/
|
||||||
|
COPY apps/server/package.json apps/server/
|
||||||
|
COPY apps/web/package.json apps/web/
|
||||||
|
RUN pnpm install
|
||||||
|
|
||||||
|
# Build the web app
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm --filter @sbt/web build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runtime
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
WEB_DIR=/app/apps/web/dist \
|
||||||
|
UPLOAD_DIR=/data/uploads \
|
||||||
|
HOST=0.0.0.0 \
|
||||||
|
PORT=3000
|
||||||
|
COPY --from=build /app ./
|
||||||
|
EXPOSE 3000
|
||||||
|
# Server runs TypeScript directly via tsx — no separate build step needed.
|
||||||
|
CMD ["pnpm", "--filter", "@sbt/server", "start"]
|
||||||
88
README.md
88
README.md
@ -1,3 +1,87 @@
|
|||||||
# scoreboardtools
|
# Scoreboard Tools
|
||||||
|
|
||||||
scoreboardtools
|
A self-hosted, real-time **Super Smash Bros Ultimate** tournament scoreboard PWA.
|
||||||
|
Log in, build scoreboards, edit a customizable scorebug, open it as a live OBS overlay,
|
||||||
|
and hand co-scorekeeping to someone else via a no-login link.
|
||||||
|
|
||||||
|
The full design rationale is in [`docs/PLAN.md`](docs/PLAN.md).
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Choice |
|
||||||
|
| ----------- | --------------------------------------------------------------------- |
|
||||||
|
| Web | React + Vite + TypeScript + Tailwind, installable PWA (`vite-plugin-pwa`) |
|
||||||
|
| Server | Fastify (REST) + raw `ws` WebSocket, run via `tsx` |
|
||||||
|
| Persistence | Postgres via Drizzle ORM (`postgres.js` driver) |
|
||||||
|
| Shared | `@sbt/shared` — zod schema + types for state and the WS protocol |
|
||||||
|
| Deploy | Docker Compose (db + server) behind your nginx/Caddy TLS reverse proxy |
|
||||||
|
|
||||||
|
Monorepo via pnpm workspaces: `apps/web`, `apps/server`, `packages/shared`.
|
||||||
|
|
||||||
|
## The real-time model (the important bit)
|
||||||
|
|
||||||
|
The WebSocket server holds each scoreboard's state **in memory** as the single source of
|
||||||
|
truth. Editors push full-state `update` messages; the server applies them, **broadcasts**
|
||||||
|
to every client in that board's room (editors + OBS overlays) for instant updates, and
|
||||||
|
**debounces** a Postgres save (~750ms) so we never write per-keystroke.
|
||||||
|
|
||||||
|
One hook — `apps/web/src/hooks/useScoreboardSync.ts` — powers all three consumers; only the
|
||||||
|
connect credential and role differ:
|
||||||
|
|
||||||
|
| Consumer | Connects with | Role |
|
||||||
|
| -------------- | -------------------- | -------- |
|
||||||
|
| Owner editor | `?board=<id>` (cookie) | editor |
|
||||||
|
| Co-scorekeeper | `?control=<token>` | editor |
|
||||||
|
| OBS overlay | `?overlay=<token>` | viewer (read-only) |
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
Requires Node 20+, pnpm, and a Postgres on `localhost:5432` (or use the compose `db`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# point the server at a database (defaults to postgres://postgres:postgres@localhost:5432/scoreboardtools)
|
||||||
|
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/scoreboardtools
|
||||||
|
export JWT_SECRET=dev-secret
|
||||||
|
|
||||||
|
pnpm dev # runs web (:5173) and server (:3000) together
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:5173 — sign up, create a scoreboard, open it, and copy the overlay
|
||||||
|
link into a second tab to watch real-time sync. Vite proxies `/api` and `/ws` to `:3000`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test # shared schema/protocol unit tests
|
||||||
|
pnpm typecheck # type-check all workspaces
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production (self-hosted, Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp deploy/.env.example .env # set JWT_SECRET (openssl rand -hex 32) and a DB password
|
||||||
|
docker compose up -d --build # brings up Postgres + the server on 127.0.0.1:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Then put your TLS reverse proxy in front (TLS is required — PWA install and OBS browser
|
||||||
|
sources need HTTPS, and the WebSocket must be `wss://`):
|
||||||
|
|
||||||
|
- **Caddy** (auto-TLS, simplest): see [`deploy/Caddyfile`](deploy/Caddyfile)
|
||||||
|
- **nginx**: see [`deploy/nginx.conf`](deploy/nginx.conf) — note the `Upgrade`/`Connection`
|
||||||
|
headers that let `/ws` pass through.
|
||||||
|
|
||||||
|
The server container serves the built web app, the REST API, the WebSocket, and uploaded
|
||||||
|
logos (persisted in the `uploads` volume).
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
All six build phases from the plan are scaffolded and wired end to end. Next session picks
|
||||||
|
up on the Linux server: `pnpm install`, bring up Docker, point the reverse proxy at it, and
|
||||||
|
verify the overlay live in OBS. See `docs/PLAN.md` → Verification.
|
||||||
|
|
||||||
|
Known follow-ups (intentionally deferred):
|
||||||
|
|
||||||
|
- Character art is **placeholder initials** — map fighter ids to real stock icons later.
|
||||||
|
- Schema is created via `ensureSchema()` on startup (idempotent `CREATE TABLE IF NOT EXISTS`);
|
||||||
|
switch to Drizzle Kit migrations once the schema starts evolving.
|
||||||
|
- `POST /api/uploads` is intentionally not auth-gated so no-login co-editors can set a logo.
|
||||||
|
|||||||
34
apps/server/package.json
Normal file
34
apps/server/package.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "@sbt/server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"start": "tsx src/server.ts",
|
||||||
|
"test": "vitest run --passWithNoTests",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^9.4.0",
|
||||||
|
"@fastify/jwt": "^8.0.1",
|
||||||
|
"@fastify/multipart": "^8.3.0",
|
||||||
|
"@fastify/static": "^7.0.4",
|
||||||
|
"@sbt/shared": "workspace:*",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"drizzle-orm": "^0.33.0",
|
||||||
|
"fastify": "^4.28.1",
|
||||||
|
"nanoid": "^5.0.7",
|
||||||
|
"postgres": "^3.4.4",
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"@types/ws": "^8.5.12",
|
||||||
|
"tsx": "^4.16.2",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vitest": "^2.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/server/src/auth.ts
Normal file
24
apps/server/src/auth.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
/** preHandler guard for owner-only REST routes. Verifies the JWT cookie. */
|
||||||
|
export async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
await req.jwtVerify();
|
||||||
|
} catch {
|
||||||
|
return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the authenticated user id from a verified request. */
|
||||||
|
export const userId = (req: FastifyRequest) => (req.user as { sub: string }).sub;
|
||||||
|
|
||||||
|
export const COOKIE_NAME = 'token';
|
||||||
|
|
||||||
|
export const cookieOptions = {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
secure: env.COOKIE_SECURE,
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||||
|
};
|
||||||
33
apps/server/src/db/index.ts
Normal file
33
apps/server/src/db/index.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { env } from '../env';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
export const sql = postgres(env.DATABASE_URL);
|
||||||
|
export const db = drizzle(sql, { schema });
|
||||||
|
export { schema };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create tables if they don't exist. Run once on startup. This keeps the
|
||||||
|
* self-hosted setup to a single `docker compose up` with no separate migration
|
||||||
|
* step. Switch to drizzle-kit migrations later if the schema starts evolving.
|
||||||
|
*/
|
||||||
|
export async function ensureSchema() {
|
||||||
|
await sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`;
|
||||||
|
await sql`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email text NOT NULL UNIQUE,
|
||||||
|
password_hash text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)`;
|
||||||
|
await sql`CREATE TABLE IF NOT EXISTS scoreboards (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
owner_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
state jsonb NOT NULL,
|
||||||
|
overlay_token text NOT NULL UNIQUE,
|
||||||
|
control_token text NOT NULL UNIQUE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)`;
|
||||||
|
}
|
||||||
22
apps/server/src/db/schema.ts
Normal file
22
apps/server/src/db/schema.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { pgTable, uuid, text, jsonb, timestamp } from 'drizzle-orm/pg-core';
|
||||||
|
import type { ScoreboardState } from '@sbt/shared';
|
||||||
|
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
passwordHash: text('password_hash').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const scoreboards = pgTable('scoreboards', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
ownerId: uuid('owner_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
state: jsonb('state').$type<ScoreboardState>().notNull(),
|
||||||
|
overlayToken: text('overlay_token').notNull().unique(),
|
||||||
|
controlToken: text('control_token').notNull().unique(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
14
apps/server/src/env.ts
Normal file
14
apps/server/src/env.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/** Central env config with sane dev defaults. Override via the environment in prod. */
|
||||||
|
export const env = {
|
||||||
|
PORT: Number(process.env.PORT ?? 3000),
|
||||||
|
HOST: process.env.HOST ?? '0.0.0.0',
|
||||||
|
DATABASE_URL:
|
||||||
|
process.env.DATABASE_URL ??
|
||||||
|
'postgres://postgres:postgres@localhost:5432/scoreboardtools',
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET ?? 'dev-secret-change-me',
|
||||||
|
// Cookies must be Secure in production (HTTPS). Defaults off for local http dev.
|
||||||
|
COOKIE_SECURE: process.env.COOKIE_SECURE === 'true',
|
||||||
|
UPLOAD_DIR: process.env.UPLOAD_DIR ?? new URL('../uploads', import.meta.url).pathname,
|
||||||
|
// Set to the built web app's dist dir in production; empty in dev (Vite serves the UI).
|
||||||
|
WEB_DIR: process.env.WEB_DIR ?? '',
|
||||||
|
};
|
||||||
114
apps/server/src/rooms.ts
Normal file
114
apps/server/src/rooms.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { WebSocket } from 'ws';
|
||||||
|
import {
|
||||||
|
ScoreboardStateSchema,
|
||||||
|
ClientMessageSchema,
|
||||||
|
type Role,
|
||||||
|
type ScoreboardState,
|
||||||
|
type ServerMessage,
|
||||||
|
} from '@sbt/shared';
|
||||||
|
import { db, schema } from './db';
|
||||||
|
|
||||||
|
const SAVE_DEBOUNCE_MS = 750;
|
||||||
|
|
||||||
|
interface Client {
|
||||||
|
ws: WebSocket;
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Room {
|
||||||
|
state: ScoreboardState;
|
||||||
|
clients: Set<Client>;
|
||||||
|
saveTimer?: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authoritative in-memory state per scoreboard. This is the core of the real-time
|
||||||
|
* design: editors' updates are applied here and broadcast to every connected client
|
||||||
|
* (editors + OBS overlays) instantly, while persistence to Postgres is debounced so
|
||||||
|
* we never write on every keystroke. Last-write-wins is fine for a 2-person board.
|
||||||
|
*/
|
||||||
|
export class RoomManager {
|
||||||
|
private rooms = new Map<string, Room>();
|
||||||
|
|
||||||
|
async join(boardId: string, ws: WebSocket, role: Role): Promise<void> {
|
||||||
|
let room = this.rooms.get(boardId);
|
||||||
|
if (!room) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ state: schema.scoreboards.state })
|
||||||
|
.from(schema.scoreboards)
|
||||||
|
.where(eq(schema.scoreboards.id, boardId));
|
||||||
|
if (!row) {
|
||||||
|
send(ws, { type: 'error', message: 'scoreboard not found' });
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
room = { state: ScoreboardStateSchema.parse(row.state), clients: new Set() };
|
||||||
|
this.rooms.set(boardId, room);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client: Client = { ws, role };
|
||||||
|
room.clients.add(client);
|
||||||
|
send(ws, { type: 'snapshot', state: room.state });
|
||||||
|
|
||||||
|
ws.on('message', (raw) => this.onMessage(boardId, client, raw.toString()));
|
||||||
|
ws.on('close', () => this.leave(boardId, client));
|
||||||
|
ws.on('error', () => this.leave(boardId, client));
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMessage(boardId: string, client: Client, raw: string) {
|
||||||
|
if (client.role !== 'editor') return; // viewers (overlays) are read-only
|
||||||
|
const room = this.rooms.get(boardId);
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msg = ClientMessageSchema.safeParse(parsed);
|
||||||
|
if (!msg.success) return;
|
||||||
|
|
||||||
|
room.state = msg.data.state;
|
||||||
|
// Broadcast authoritative state to everyone except the sender (it already has it).
|
||||||
|
const out: ServerMessage = { type: 'state', state: room.state };
|
||||||
|
for (const c of room.clients) if (c !== client) send(c.ws, out);
|
||||||
|
this.scheduleSave(boardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleSave(boardId: string) {
|
||||||
|
const room = this.rooms.get(boardId);
|
||||||
|
if (!room) return;
|
||||||
|
if (room.saveTimer) clearTimeout(room.saveTimer);
|
||||||
|
room.saveTimer = setTimeout(() => void this.flush(boardId), SAVE_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flush(boardId: string) {
|
||||||
|
const room = this.rooms.get(boardId);
|
||||||
|
if (!room) return;
|
||||||
|
room.saveTimer = undefined;
|
||||||
|
await db
|
||||||
|
.update(schema.scoreboards)
|
||||||
|
.set({ state: room.state, updatedAt: new Date() })
|
||||||
|
.where(eq(schema.scoreboards.id, boardId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private leave(boardId: string, client: Client) {
|
||||||
|
const room = this.rooms.get(boardId);
|
||||||
|
if (!room || !room.clients.has(client)) return;
|
||||||
|
room.clients.delete(client);
|
||||||
|
// When the last client disconnects, flush pending state and unload the room.
|
||||||
|
if (room.clients.size === 0) {
|
||||||
|
if (room.saveTimer) clearTimeout(room.saveTimer);
|
||||||
|
void this.flush(boardId).finally(() => {
|
||||||
|
const r = this.rooms.get(boardId);
|
||||||
|
if (r && r.clients.size === 0) this.rooms.delete(boardId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(ws: WebSocket, msg: ServerMessage) {
|
||||||
|
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
66
apps/server/src/routes/auth.ts
Normal file
66
apps/server/src/routes/auth.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import { COOKIE_NAME, cookieOptions, requireAuth, userId } from '../auth';
|
||||||
|
|
||||||
|
const Credentials = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(6).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function authRoutes(app: FastifyInstance) {
|
||||||
|
function login(reply: FastifyReply, id: string) {
|
||||||
|
const token = app.jwt.sign({ sub: id }, { expiresIn: '30d' });
|
||||||
|
reply.setCookie(COOKIE_NAME, token, cookieOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/api/signup', async (req, reply) => {
|
||||||
|
const body = Credentials.safeParse(req.body);
|
||||||
|
if (!body.success) return reply.code(400).send({ error: 'invalid email or password' });
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: schema.users.id })
|
||||||
|
.from(schema.users)
|
||||||
|
.where(eq(schema.users.email, body.data.email));
|
||||||
|
if (existing.length) return reply.code(409).send({ error: 'email already in use' });
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(body.data.password, 10);
|
||||||
|
const [user] = await db
|
||||||
|
.insert(schema.users)
|
||||||
|
.values({ email: body.data.email, passwordHash })
|
||||||
|
.returning({ id: schema.users.id, email: schema.users.email });
|
||||||
|
login(reply, user.id);
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/login', async (req, reply) => {
|
||||||
|
const body = Credentials.safeParse(req.body);
|
||||||
|
if (!body.success) return reply.code(400).send({ error: 'invalid credentials' });
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.users)
|
||||||
|
.where(eq(schema.users.email, body.data.email));
|
||||||
|
if (!user || !(await bcrypt.compare(body.data.password, user.passwordHash))) {
|
||||||
|
return reply.code(401).send({ error: 'invalid credentials' });
|
||||||
|
}
|
||||||
|
login(reply, user.id);
|
||||||
|
return { id: user.id, email: user.email };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/logout', async (_req, reply) => {
|
||||||
|
reply.clearCookie(COOKIE_NAME, { path: '/' });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/me', { preHandler: requireAuth }, async (req, reply) => {
|
||||||
|
const [user] = await db
|
||||||
|
.select({ id: schema.users.id, email: schema.users.email })
|
||||||
|
.from(schema.users)
|
||||||
|
.where(eq(schema.users.id, userId(req)));
|
||||||
|
if (!user) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
}
|
||||||
81
apps/server/src/routes/scoreboards.ts
Normal file
81
apps/server/src/routes/scoreboards.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { and, desc, eq } from 'drizzle-orm';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { defaultState, ScoreboardStateSchema } from '@sbt/shared';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import { requireAuth, userId } from '../auth';
|
||||||
|
|
||||||
|
const IdParams = z.object({ id: z.string().uuid() });
|
||||||
|
|
||||||
|
export async function scoreboardRoutes(app: FastifyInstance) {
|
||||||
|
// All scoreboard CRUD is owner-only. The public flows go through WebSocket tokens.
|
||||||
|
app.addHook('preHandler', requireAuth);
|
||||||
|
|
||||||
|
app.get('/api/scoreboards', async (req) => {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: schema.scoreboards.id,
|
||||||
|
name: schema.scoreboards.name,
|
||||||
|
overlayToken: schema.scoreboards.overlayToken,
|
||||||
|
controlToken: schema.scoreboards.controlToken,
|
||||||
|
updatedAt: schema.scoreboards.updatedAt,
|
||||||
|
})
|
||||||
|
.from(schema.scoreboards)
|
||||||
|
.where(eq(schema.scoreboards.ownerId, userId(req)))
|
||||||
|
.orderBy(desc(schema.scoreboards.updatedAt));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/scoreboards', async (req, reply) => {
|
||||||
|
const body = z.object({ name: z.string().min(1).max(80) }).safeParse(req.body);
|
||||||
|
if (!body.success) return reply.code(400).send({ error: 'name required' });
|
||||||
|
const [row] = await db
|
||||||
|
.insert(schema.scoreboards)
|
||||||
|
.values({
|
||||||
|
ownerId: userId(req),
|
||||||
|
name: body.data.name,
|
||||||
|
state: defaultState(),
|
||||||
|
overlayToken: nanoid(16),
|
||||||
|
controlToken: nanoid(16),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/scoreboards/:id', async (req, reply) => {
|
||||||
|
const params = IdParams.safeParse(req.params);
|
||||||
|
if (!params.success) return reply.code(400).send({ error: 'bad id' });
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.scoreboards)
|
||||||
|
.where(and(eq(schema.scoreboards.id, params.data.id), eq(schema.scoreboards.ownerId, userId(req))));
|
||||||
|
if (!row) return reply.code(404).send({ error: 'not found' });
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/scoreboards/:id', async (req, reply) => {
|
||||||
|
const params = IdParams.safeParse(req.params);
|
||||||
|
if (!params.success) return reply.code(400).send({ error: 'bad id' });
|
||||||
|
const body = z
|
||||||
|
.object({ name: z.string().min(1).max(80).optional(), state: ScoreboardStateSchema.optional() })
|
||||||
|
.safeParse(req.body);
|
||||||
|
if (!body.success) return reply.code(400).send({ error: 'invalid body' });
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.update(schema.scoreboards)
|
||||||
|
.set({ ...body.data, updatedAt: new Date() })
|
||||||
|
.where(and(eq(schema.scoreboards.id, params.data.id), eq(schema.scoreboards.ownerId, userId(req))))
|
||||||
|
.returning();
|
||||||
|
if (!row) return reply.code(404).send({ error: 'not found' });
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/scoreboards/:id', async (req, reply) => {
|
||||||
|
const params = IdParams.safeParse(req.params);
|
||||||
|
if (!params.success) return reply.code(400).send({ error: 'bad id' });
|
||||||
|
await db
|
||||||
|
.delete(schema.scoreboards)
|
||||||
|
.where(and(eq(schema.scoreboards.id, params.data.id), eq(schema.scoreboards.ownerId, userId(req))));
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
31
apps/server/src/routes/uploads.ts
Normal file
31
apps/server/src/routes/uploads.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { createWriteStream } from 'node:fs';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
const ALLOWED = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event-logo upload. Intentionally NOT auth-gated so that co-editors using a
|
||||||
|
* control-token link (no login) can also set a logo. Limited to small image files.
|
||||||
|
*/
|
||||||
|
export async function uploadRoutes(app: FastifyInstance, opts: { uploadDir: string }) {
|
||||||
|
app.post('/api/uploads', async (req, reply) => {
|
||||||
|
const file = await req.file();
|
||||||
|
if (!file) return reply.code(400).send({ error: 'no file' });
|
||||||
|
|
||||||
|
const ext = path.extname(file.filename || '').toLowerCase();
|
||||||
|
if (!ALLOWED.has(ext)) return reply.code(400).send({ error: 'unsupported file type' });
|
||||||
|
|
||||||
|
const name = `${nanoid(12)}${ext}`;
|
||||||
|
await pipeline(file.file, createWriteStream(path.join(opts.uploadDir, name)));
|
||||||
|
|
||||||
|
if (file.file.truncated) {
|
||||||
|
fs.rmSync(path.join(opts.uploadDir, name), { force: true });
|
||||||
|
return reply.code(413).send({ error: 'file too large' });
|
||||||
|
}
|
||||||
|
return { url: `/uploads/${name}` };
|
||||||
|
});
|
||||||
|
}
|
||||||
59
apps/server/src/server.ts
Normal file
59
apps/server/src/server.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import cookie from '@fastify/cookie';
|
||||||
|
import jwt from '@fastify/jwt';
|
||||||
|
import multipart from '@fastify/multipart';
|
||||||
|
import fstatic from '@fastify/static';
|
||||||
|
import { env } from './env';
|
||||||
|
import { ensureSchema } from './db';
|
||||||
|
import { COOKIE_NAME } from './auth';
|
||||||
|
import { authRoutes } from './routes/auth';
|
||||||
|
import { scoreboardRoutes } from './routes/scoreboards';
|
||||||
|
import { uploadRoutes } from './routes/uploads';
|
||||||
|
import { setupWebSocket } from './ws';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await ensureSchema();
|
||||||
|
|
||||||
|
const app = Fastify({ logger: true });
|
||||||
|
|
||||||
|
await app.register(cookie);
|
||||||
|
await app.register(jwt, {
|
||||||
|
secret: env.JWT_SECRET,
|
||||||
|
cookie: { cookieName: COOKIE_NAME, signed: false },
|
||||||
|
});
|
||||||
|
await app.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } });
|
||||||
|
|
||||||
|
// Uploaded logos, served from a persistent volume in production.
|
||||||
|
const uploadDir = path.resolve(env.UPLOAD_DIR);
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
await app.register(fstatic, { root: uploadDir, prefix: '/uploads/', decorateReply: false });
|
||||||
|
|
||||||
|
await app.register(authRoutes);
|
||||||
|
await app.register(scoreboardRoutes);
|
||||||
|
await app.register(uploadRoutes, { uploadDir });
|
||||||
|
|
||||||
|
// In production the server also serves the built React app (single container).
|
||||||
|
const webDir = env.WEB_DIR ? path.resolve(env.WEB_DIR) : '';
|
||||||
|
if (webDir && fs.existsSync(webDir)) {
|
||||||
|
await app.register(fstatic, { root: webDir, prefix: '/' });
|
||||||
|
// SPA fallback: send index.html for client-side routes (not /api, /uploads, /ws).
|
||||||
|
app.setNotFoundHandler((req, reply) => {
|
||||||
|
if (req.method === 'GET' && !/^\/(api|uploads|ws)\b/.test(req.url)) {
|
||||||
|
return reply.sendFile('index.html');
|
||||||
|
}
|
||||||
|
return reply.code(404).send({ error: 'not found' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupWebSocket(app);
|
||||||
|
|
||||||
|
await app.listen({ port: env.PORT, host: env.HOST });
|
||||||
|
app.log.info(`scoreboardtools server listening on ${env.HOST}:${env.PORT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
103
apps/server/src/ws.ts
Normal file
103
apps/server/src/ws.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { WS_PATH, type Role } from '@sbt/shared';
|
||||||
|
import { db, schema } from './db';
|
||||||
|
import { COOKIE_NAME } from './auth';
|
||||||
|
import { RoomManager } from './rooms';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the WebSocket server to Fastify's HTTP server. The connect query string
|
||||||
|
* determines which board and role a socket gets:
|
||||||
|
* ?overlay=<token> -> read-only viewer (OBS)
|
||||||
|
* ?control=<token> -> editor, no login (shared scorekeeping link)
|
||||||
|
* ?board=<id> -> editor, owner (auth via JWT cookie)
|
||||||
|
*/
|
||||||
|
export function setupWebSocket(app: FastifyInstance) {
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
const rooms = new RoomManager();
|
||||||
|
|
||||||
|
app.server.on('upgrade', (req, socket, head) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url ?? '', 'http://localhost');
|
||||||
|
if (url.pathname !== WS_PATH) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = await resolveConnection(app, url, req.headers.cookie);
|
||||||
|
if (!resolved) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
void rooms.join(resolved.boardId, ws, resolved.role);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveConnection(
|
||||||
|
app: FastifyInstance,
|
||||||
|
url: URL,
|
||||||
|
cookieHeader: string | undefined,
|
||||||
|
): Promise<{ boardId: string; role: Role } | null> {
|
||||||
|
const overlay = url.searchParams.get('overlay');
|
||||||
|
const control = url.searchParams.get('control');
|
||||||
|
const board = url.searchParams.get('board');
|
||||||
|
|
||||||
|
if (overlay) {
|
||||||
|
const id = await boardIdByToken('overlayToken', overlay);
|
||||||
|
return id ? { boardId: id, role: 'viewer' } : null;
|
||||||
|
}
|
||||||
|
if (control) {
|
||||||
|
const id = await boardIdByToken('controlToken', control);
|
||||||
|
return id ? { boardId: id, role: 'editor' } : null;
|
||||||
|
}
|
||||||
|
if (board) {
|
||||||
|
const uid = verifyCookie(app, cookieHeader);
|
||||||
|
if (!uid) return null;
|
||||||
|
const [row] = await db
|
||||||
|
.select({ id: schema.scoreboards.id })
|
||||||
|
.from(schema.scoreboards)
|
||||||
|
.where(and(eq(schema.scoreboards.id, board), eq(schema.scoreboards.ownerId, uid)));
|
||||||
|
return row ? { boardId: row.id, role: 'editor' } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boardIdByToken(
|
||||||
|
column: 'overlayToken' | 'controlToken',
|
||||||
|
token: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ id: schema.scoreboards.id })
|
||||||
|
.from(schema.scoreboards)
|
||||||
|
.where(eq(schema.scoreboards[column], token));
|
||||||
|
return row?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyCookie(app: FastifyInstance, cookieHeader: string | undefined): string | null {
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
const token = parseCookies(cookieHeader)[COOKIE_NAME];
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
return (app.jwt.verify(token) as { sub: string }).sub;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookies(header: string): Record<string, string> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
header.split(';').map((c) => {
|
||||||
|
const i = c.indexOf('=');
|
||||||
|
return [c.slice(0, i).trim(), decodeURIComponent(c.slice(i + 1).trim())];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/server/tsconfig.json
Normal file
9
apps/server/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"types": ["node"],
|
||||||
|
"moduleResolution": "Bundler"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
14
apps/web/index.html
Normal file
14
apps/web/index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#0d1b2a" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
|
<title>Scoreboard Tools</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
apps/web/package.json
Normal file
29
apps/web/package.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@sbt/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@sbt/shared": "workspace:*",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"postcss": "^8.4.40",
|
||||||
|
"tailwindcss": "^3.4.7",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.0",
|
||||||
|
"vite-plugin-pwa": "^0.20.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
12
apps/web/public/icon.svg
Normal file
12
apps/web/public/icon.svg
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<rect width="512" height="512" rx="96" fill="#0d1b2a"/>
|
||||||
|
<rect x="56" y="176" width="400" height="160" rx="20" fill="#162534" stroke="#24384c" stroke-width="6"/>
|
||||||
|
<rect x="196" y="176" width="120" height="160" fill="#e63946"/>
|
||||||
|
<text x="256" y="276" font-family="system-ui, sans-serif" font-size="86" font-weight="800"
|
||||||
|
fill="#ffffff" text-anchor="middle" dominant-baseline="middle">0–0</text>
|
||||||
|
<circle cx="96" cy="370" r="12" fill="#e63946"/>
|
||||||
|
<circle cx="128" cy="370" r="12" fill="#e63946"/>
|
||||||
|
<circle cx="160" cy="370" r="12" fill="#e63946"/>
|
||||||
|
<circle cx="416" cy="370" r="12" fill="#e63946"/>
|
||||||
|
<circle cx="384" cy="370" r="12" fill="#e63946"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 762 B |
31
apps/web/src/App.tsx
Normal file
31
apps/web/src/App.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { AuthProvider, useAuth } from './auth/AuthContext';
|
||||||
|
import { Login, Signup } from './pages/Login';
|
||||||
|
import { Dashboard } from './pages/Dashboard';
|
||||||
|
import { Editor } from './pages/Editor';
|
||||||
|
import { Control } from './pages/Control';
|
||||||
|
import { Overlay } from './pages/Overlay';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
function Protected({ children }: { children: ReactNode }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
if (loading) return <div className="grid min-h-screen place-items-center bg-ink text-slate-400">Loading…</div>;
|
||||||
|
return user ? <>{children}</> : <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/signup" element={<Signup />} />
|
||||||
|
<Route path="/" element={<Protected><Dashboard /></Protected>} />
|
||||||
|
<Route path="/scoreboard/:id" element={<Protected><Editor /></Protected>} />
|
||||||
|
{/* Public, no-login routes guarded only by unguessable tokens */}
|
||||||
|
<Route path="/control/:token" element={<Control />} />
|
||||||
|
<Route path="/overlay/:token" element={<Overlay />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
apps/web/src/api.ts
Normal file
51
apps/web/src/api.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
/** Thin REST client. Cookies (the JWT session) ride along automatically. */
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
export interface BoardSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
overlayToken: string;
|
||||||
|
controlToken: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function req<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: opts.body ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((msg as { error?: string }).error || res.statusText);
|
||||||
|
}
|
||||||
|
return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
me: () => req<User>('/api/me'),
|
||||||
|
signup: (email: string, password: string) =>
|
||||||
|
req<User>('/api/signup', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||||
|
login: (email: string, password: string) =>
|
||||||
|
req<User>('/api/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||||
|
logout: () => req<{ ok: true }>('/api/logout', { method: 'POST' }),
|
||||||
|
|
||||||
|
listBoards: () => req<BoardSummary[]>('/api/scoreboards'),
|
||||||
|
createBoard: (name: string) =>
|
||||||
|
req<BoardSummary>('/api/scoreboards', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
|
getBoard: (id: string) => req<BoardSummary>(`/api/scoreboards/${id}`),
|
||||||
|
renameBoard: (id: string, name: string) =>
|
||||||
|
req<BoardSummary>(`/api/scoreboards/${id}`, { method: 'PATCH', body: JSON.stringify({ name }) }),
|
||||||
|
deleteBoard: (id: string) => req<void>(`/api/scoreboards/${id}`, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Multipart logo upload; returns the served URL to store in state.eventLogoUrl. */
|
||||||
|
export async function uploadImage(file: File): Promise<string> {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const res = await fetch('/api/uploads', { method: 'POST', body: fd, credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error('upload failed');
|
||||||
|
return ((await res.json()) as { url: string }).url;
|
||||||
|
}
|
||||||
44
apps/web/src/auth/AuthContext.tsx
Normal file
44
apps/web/src/auth/AuthContext.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||||
|
import { api, type User } from '../api';
|
||||||
|
|
||||||
|
interface AuthValue {
|
||||||
|
user: User | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
signup: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<AuthValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.me()
|
||||||
|
.then(setUser)
|
||||||
|
.catch(() => setUser(null))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value: AuthValue = {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
login: async (email, password) => setUser(await api.login(email, password)),
|
||||||
|
signup: async (email, password) => setUser(await api.signup(email, password)),
|
||||||
|
logout: async () => {
|
||||||
|
await api.logout();
|
||||||
|
setUser(null);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(Ctx);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
62
apps/web/src/components/CharacterPicker.tsx
Normal file
62
apps/web/src/components/CharacterPicker.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/web/src/components/MatchControls.tsx
Normal file
76
apps/web/src/components/MatchControls.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
apps/web/src/components/PlayerPanel.tsx
Normal file
51
apps/web/src/components/PlayerPanel.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { clamp, type Player } from '@sbt/shared';
|
||||||
|
import { Button, Field, Input, Stepper } from './ui';
|
||||||
|
import { CharacterPicker } from './CharacterPicker';
|
||||||
|
|
||||||
|
/** Editing controls for one player. `onChange` receives a partial player patch. */
|
||||||
|
export function PlayerPanel({
|
||||||
|
index,
|
||||||
|
player,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
index: 0 | 1;
|
||||||
|
player: Player;
|
||||||
|
onChange: (patch: Partial<Player>) => void;
|
||||||
|
}) {
|
||||||
|
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">Player {index + 1}</div>
|
||||||
|
|
||||||
|
<Field label="Name">
|
||||||
|
<Input value={player.name} onChange={(e) => onChange({ name: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Character">
|
||||||
|
<CharacterPicker value={player.character} onChange={(character) => onChange({ character })} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Callout / Subtitle">
|
||||||
|
<Input
|
||||||
|
value={player.subtitle}
|
||||||
|
placeholder="e.g. Team Liquid"
|
||||||
|
onChange={(e) => onChange({ subtitle: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wide text-slate-400">Score</span>
|
||||||
|
<Stepper value={player.score} onChange={(score) => onChange({ score })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wide text-slate-400">Stocks</span>
|
||||||
|
<Stepper value={player.stocks} max={9} onChange={(stocks) => onChange({ stocks: clamp(stocks, 0, 9) })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button className="flex-1" onClick={() => onChange({ score: player.score + 1 })}>+1 Game</Button>
|
||||||
|
<Button className="flex-1" variant="ghost" onClick={() => onChange({ score: 0, stocks: 3 })}>Reset</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
apps/web/src/components/ScoreBug.tsx
Normal file
52
apps/web/src/components/ScoreBug.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { fighterInitials, type Player, type ScoreboardState } from '@sbt/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rendered scorebug. Pure function of state — used in the editor preview, the
|
||||||
|
* control panel preview, and the OBS overlay so they always look identical.
|
||||||
|
*/
|
||||||
|
export function ScoreBug({ state }: { state: ScoreboardState }) {
|
||||||
|
const [left, right] = state.swapped
|
||||||
|
? [state.players[1], state.players[0]]
|
||||||
|
: [state.players[0], state.players[1]];
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
'--accent': state.theme.accent,
|
||||||
|
'--bg': state.theme.bg,
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scorebug" style={style}>
|
||||||
|
<PlayerSide player={left} side="left" />
|
||||||
|
<div className="scorebug-center">
|
||||||
|
{state.eventLogoUrl && <img className="scorebug-logo" src={state.eventLogoUrl} alt="" />}
|
||||||
|
<div className="scorebug-scores">
|
||||||
|
<span>{left.score}</span>
|
||||||
|
<span className="dash">–</span>
|
||||||
|
<span>{right.score}</span>
|
||||||
|
</div>
|
||||||
|
{state.centerCallout && <div className="scorebug-callout">{state.centerCallout}</div>}
|
||||||
|
{state.bestOf > 0 && <div className="scorebug-bo">Best of {state.bestOf}</div>}
|
||||||
|
</div>
|
||||||
|
<PlayerSide player={right} side="right" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayerSide({ player, side }: { player: Player; side: 'left' | 'right' }) {
|
||||||
|
return (
|
||||||
|
<div className={`scorebug-side ${side}`}>
|
||||||
|
<div className="scorebug-portrait" title={player.character || 'No character'}>
|
||||||
|
{fighterInitials(player.character)}
|
||||||
|
</div>
|
||||||
|
<div className="scorebug-names">
|
||||||
|
<span className="scorebug-name">{player.name || 'Player'}</span>
|
||||||
|
{player.subtitle && <span className="scorebug-subtitle">{player.subtitle}</span>}
|
||||||
|
<div className="scorebug-stocks">
|
||||||
|
{Array.from({ length: player.stocks }).map((_, i) => (
|
||||||
|
<span key={i} className="scorebug-stock" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
apps/web/src/components/ShareLinks.tsx
Normal file
36
apps/web/src/components/ShareLinks.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from './ui';
|
||||||
|
|
||||||
|
/** Copyable overlay (read-only, for OBS) and control (co-edit, no login) links. */
|
||||||
|
export function ShareLinks({ overlayToken, controlToken }: { overlayToken: string; controlToken: string }) {
|
||||||
|
const origin = location.origin;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border border-edge bg-panel p-4">
|
||||||
|
<div className="text-xs font-bold uppercase tracking-widest text-slate-500">Share</div>
|
||||||
|
<LinkRow label="OBS overlay (read-only)" url={`${origin}/overlay/${overlayToken}`} />
|
||||||
|
<LinkRow label="Co-scorekeeper (can edit, no login)" url={`${origin}/control/${controlToken}`} />
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Add the overlay URL as a Browser Source in OBS. The control link lets someone else update
|
||||||
|
the score live without an account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkRow({ label, url }: { label: string; url: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copy = async () => {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-xs font-medium text-slate-400">{label}</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input readOnly value={url} className="flex-1 truncate rounded border border-edge bg-ink px-2 py-1 text-xs" />
|
||||||
|
<Button onClick={copy}>{copied ? 'Copied' : 'Copy'}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
apps/web/src/components/Workspace.tsx
Normal file
49
apps/web/src/components/Workspace.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
apps/web/src/components/ui.tsx
Normal file
52
apps/web/src/components/ui.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
className = '',
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'default' | 'primary' | 'ghost' | 'danger' }) {
|
||||||
|
const variants = {
|
||||||
|
default: 'bg-edge hover:bg-edge/70 text-white',
|
||||||
|
primary: 'bg-red-500 hover:bg-red-600 text-white',
|
||||||
|
ghost: 'bg-transparent hover:bg-edge/50 text-slate-200',
|
||||||
|
danger: 'bg-transparent hover:bg-red-500/20 text-red-400',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`rounded-md px-3 py-1.5 text-sm font-medium transition disabled:opacity-50 ${variants[variant]} ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({ className = '', ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={`w-full rounded-md border border-edge bg-ink px-3 py-2 text-sm text-white outline-none focus:border-red-500 ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Stepper({ value, onChange, min = 0, max = 99 }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button onClick={() => onChange(Math.max(min, value - 1))} aria-label="decrease">−</Button>
|
||||||
|
<span className="w-8 text-center text-lg font-bold tabular-nums">{value}</span>
|
||||||
|
<Button onClick={() => onChange(Math.min(max, value + 1))} aria-label="increase">+</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="flex flex-col gap-1 text-xs font-medium uppercase tracking-wide text-slate-400">
|
||||||
|
{label}
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||||
|
return <div className={`rounded-lg border border-edge bg-panel p-4 ${className}`}>{children}</div>;
|
||||||
|
}
|
||||||
75
apps/web/src/hooks/useScoreboardSync.ts
Normal file
75
apps/web/src/hooks/useScoreboardSync.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
WS_PATH,
|
||||||
|
type Role,
|
||||||
|
type ScoreboardState,
|
||||||
|
type ServerMessage,
|
||||||
|
} from '@sbt/shared';
|
||||||
|
|
||||||
|
function wsUrl(query: string): string {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
return `${proto}://${location.host}${WS_PATH}?${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one real-time primitive. Opens a WebSocket, keeps local state in sync with
|
||||||
|
* the server's authoritative snapshots/broadcasts, and (for editors) pushes updates.
|
||||||
|
*
|
||||||
|
* Reused identically by three consumers — only the connect `query` and `role` differ:
|
||||||
|
* editor: `board=<id>` role 'editor'
|
||||||
|
* control: `control=<token>` role 'editor'
|
||||||
|
* overlay: `overlay=<token>` role 'viewer'
|
||||||
|
*/
|
||||||
|
export function useScoreboardSync(query: string, role: Role) {
|
||||||
|
const [state, setState] = useState<ScoreboardState | null>(null);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let closed = false;
|
||||||
|
let retry = 0;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
const ws = new WebSocket(wsUrl(query));
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
retry = 0;
|
||||||
|
setConnected(true);
|
||||||
|
};
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const msg = JSON.parse(e.data as string) as ServerMessage;
|
||||||
|
if (msg.type === 'snapshot' || msg.type === 'state') setState(msg.state);
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
setConnected(false);
|
||||||
|
if (closed) return;
|
||||||
|
retry += 1;
|
||||||
|
timer = setTimeout(connect, Math.min(1000 * retry, 5000)); // backoff, re-snapshots on reconnect
|
||||||
|
};
|
||||||
|
ws.onerror = () => ws.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
closed = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
wsRef.current?.close();
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
/** Optimistically apply locally, then broadcast to the room (editors only). */
|
||||||
|
const sendUpdate = useCallback(
|
||||||
|
(next: ScoreboardState) => {
|
||||||
|
setState(next);
|
||||||
|
const ws = wsRef.current;
|
||||||
|
if (role === 'editor' && ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'update', state: next }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[role],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { state, sendUpdate, connected };
|
||||||
|
}
|
||||||
142
apps/web/src/index.css
Normal file
142
apps/web/src/index.css
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scorebug ---------------------------------------------------------------
|
||||||
|
Self-contained component styling (CSS vars driven by state.theme) so it renders
|
||||||
|
identically in the editor preview, the control panel, and the OBS overlay. */
|
||||||
|
.scorebug {
|
||||||
|
--accent: #e63946;
|
||||||
|
--bg: #0d1b2a;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 960px;
|
||||||
|
max-width: 100%;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scorebug-side {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: linear-gradient(0deg, rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.25)), var(--bg);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.scorebug-side.right {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scorebug-portrait {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 20px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scorebug-names {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.scorebug-name {
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.scorebug-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.75;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scorebug-stocks {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.scorebug-stock {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scorebug-center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 22px;
|
||||||
|
background: var(--accent);
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
.scorebug-logo {
|
||||||
|
max-height: 34px;
|
||||||
|
max-width: 120px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.scorebug-scores {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 44px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.scorebug-scores .dash {
|
||||||
|
font-size: 28px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.scorebug-callout {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.scorebug-bo {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* OBS overlay: transparent page, centered bug, no app chrome. */
|
||||||
|
.overlay-root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
13
apps/web/src/main.tsx
Normal file
13
apps/web/src/main.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
25
apps/web/src/pages/Control.tsx
Normal file
25
apps/web/src/pages/Control.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useScoreboardSync } from '../hooks/useScoreboardSync';
|
||||||
|
import { Workspace } from '../components/Workspace';
|
||||||
|
|
||||||
|
/** Public co-scorekeeping page. No login — access is via the unguessable control token. */
|
||||||
|
export function Control() {
|
||||||
|
const { token = '' } = useParams();
|
||||||
|
const { state, sendUpdate, connected } = useScoreboardSync(`control=${token}`, 'editor');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-ink text-white">
|
||||||
|
<header className="border-b border-edge px-6 py-4">
|
||||||
|
<h1 className="text-lg font-bold">Scorekeeper</h1>
|
||||||
|
<p className="text-xs text-slate-500">Shared control — your changes go live instantly.</p>
|
||||||
|
</header>
|
||||||
|
<main className="mx-auto max-w-5xl p-6">
|
||||||
|
{state ? (
|
||||||
|
<Workspace state={state} onChange={sendUpdate} connected={connected} />
|
||||||
|
) : (
|
||||||
|
<p className="text-slate-400">Connecting…</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
apps/web/src/pages/Dashboard.tsx
Normal file
74
apps/web/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api, type BoardSummary } from '../api';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { Button, Card, Input } from '../components/ui';
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const [boards, setBoards] = useState<BoardSummary[]>([]);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refresh = () => api.listBoards().then(setBoards).finally(() => setLoading(false));
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const create = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
await api.createBoard(name.trim());
|
||||||
|
setName('');
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (id: string) => {
|
||||||
|
await api.deleteBoard(id);
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-ink text-white">
|
||||||
|
<header className="flex items-center justify-between border-b border-edge px-6 py-4">
|
||||||
|
<h1 className="text-lg font-bold">Scoreboard Tools</h1>
|
||||||
|
<div className="flex items-center gap-3 text-sm text-slate-400">
|
||||||
|
<span>{user?.email}</span>
|
||||||
|
<Button variant="ghost" onClick={logout}>Sign out</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-3xl p-6">
|
||||||
|
<form onSubmit={create} className="mb-6 flex gap-2">
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="New scoreboard name…" />
|
||||||
|
<Button type="submit" variant="primary">Create</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-slate-400">Loading…</p>
|
||||||
|
) : boards.length === 0 ? (
|
||||||
|
<p className="text-slate-400">No scoreboards yet. Create one above.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{boards.map((b) => (
|
||||||
|
<Card key={b.id} className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">{b.name}</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
Updated {new Date(b.updatedAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link to={`/scoreboard/${b.id}`}>
|
||||||
|
<Button variant="primary">Open</Button>
|
||||||
|
</Link>
|
||||||
|
<Button variant="danger" onClick={() => remove(b.id)}>Delete</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/web/src/pages/Editor.tsx
Normal file
38
apps/web/src/pages/Editor.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import { api, type BoardSummary } from '../api';
|
||||||
|
import { useScoreboardSync } from '../hooks/useScoreboardSync';
|
||||||
|
import { Workspace } from '../components/Workspace';
|
||||||
|
import { ShareLinks } from '../components/ShareLinks';
|
||||||
|
|
||||||
|
export function Editor() {
|
||||||
|
const { id = '' } = useParams();
|
||||||
|
const [meta, setMeta] = useState<BoardSummary | null>(null);
|
||||||
|
const { state, sendUpdate, connected } = useScoreboardSync(`board=${id}`, 'editor');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getBoard(id).then(setMeta).catch(() => setMeta(null));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-ink text-white">
|
||||||
|
<header className="flex items-center justify-between border-b border-edge px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link to="/" className="text-sm text-slate-400 hover:text-white">← Dashboard</Link>
|
||||||
|
<h1 className="text-lg font-bold">{meta?.name ?? 'Scoreboard'}</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-5xl space-y-6 p-6">
|
||||||
|
{state ? (
|
||||||
|
<>
|
||||||
|
<Workspace state={state} onChange={sendUpdate} connected={connected} />
|
||||||
|
{meta && <ShareLinks overlayToken={meta.overlayToken} controlToken={meta.controlToken} />}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-slate-400">Connecting…</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
apps/web/src/pages/Login.tsx
Normal file
64
apps/web/src/pages/Login.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { Button, Card, Field, Input } from '../components/ui';
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
return <AuthForm mode="login" />;
|
||||||
|
}
|
||||||
|
export function Signup() {
|
||||||
|
return <AuthForm mode="signup" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthForm({ mode }: { mode: 'login' | 'signup' }) {
|
||||||
|
const { login, signup } = useAuth();
|
||||||
|
const nav = useNavigate();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await (mode === 'login' ? login(email, password) : signup(email, password));
|
||||||
|
nav('/');
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-screen place-items-center bg-ink p-4 text-white">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<h1 className="mb-1 text-xl font-bold">Scoreboard Tools</h1>
|
||||||
|
<p className="mb-4 text-sm text-slate-400">
|
||||||
|
{mode === 'login' ? 'Sign in to your dashboard' : 'Create an account'}
|
||||||
|
</p>
|
||||||
|
<form onSubmit={submit} className="flex flex-col gap-3">
|
||||||
|
<Field label="Email">
|
||||||
|
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||||
|
</Field>
|
||||||
|
<Field label="Password">
|
||||||
|
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={6} />
|
||||||
|
</Field>
|
||||||
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||||
|
<Button type="submit" variant="primary" disabled={busy}>
|
||||||
|
{busy ? '…' : mode === 'login' ? 'Sign in' : 'Sign up'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p className="mt-4 text-center text-sm text-slate-400">
|
||||||
|
{mode === 'login' ? (
|
||||||
|
<>No account? <Link className="text-red-400" to="/signup">Sign up</Link></>
|
||||||
|
) : (
|
||||||
|
<>Have an account? <Link className="text-red-400" to="/login">Sign in</Link></>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/web/src/pages/Overlay.tsx
Normal file
26
apps/web/src/pages/Overlay.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useScoreboardSync } from '../hooks/useScoreboardSync';
|
||||||
|
import { ScoreBug } from '../components/ScoreBug';
|
||||||
|
|
||||||
|
/** Read-only OBS overlay. Transparent background, just the scorebug, live-updating. */
|
||||||
|
export function Overlay() {
|
||||||
|
const { token = '' } = useParams();
|
||||||
|
const { state } = useScoreboardSync(`overlay=${token}`, 'viewer');
|
||||||
|
|
||||||
|
// Make the page itself transparent so OBS composites cleanly.
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = document.body.style.background;
|
||||||
|
document.body.style.background = 'transparent';
|
||||||
|
return () => {
|
||||||
|
document.body.style.background = prev;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!state) return null;
|
||||||
|
return (
|
||||||
|
<div className="overlay-root">
|
||||||
|
<ScoreBug state={state} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
apps/web/tailwind.config.js
Normal file
14
apps/web/tailwind.config.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
ink: '#0d1b2a',
|
||||||
|
panel: '#162534',
|
||||||
|
edge: '#24384c',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
11
apps/web/tsconfig.json
Normal file
11
apps/web/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
36
apps/web/vite.config.ts
Normal file
36
apps/web/vite.config.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
|
||||||
|
// Dev proxy forwards API + WebSocket to the Fastify server on :3000 so the web
|
||||||
|
// app and server share an origin (matching the production reverse-proxy setup).
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
// Never cache the OBS overlay route — it must always be live.
|
||||||
|
workbox: { navigateFallbackDenylist: [/^\/overlay\//] },
|
||||||
|
manifest: {
|
||||||
|
name: 'Scoreboard Tools',
|
||||||
|
short_name: 'Scoreboard',
|
||||||
|
description: 'Real-time Super Smash Bros Ultimate tournament scoreboards',
|
||||||
|
theme_color: '#0d1b2a',
|
||||||
|
background_color: '#0d1b2a',
|
||||||
|
display: 'standalone',
|
||||||
|
start_url: '/',
|
||||||
|
icons: [
|
||||||
|
{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any maskable' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
'/uploads': 'http://localhost:3000',
|
||||||
|
'/ws': { target: 'ws://localhost:3000', ws: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
11
deploy/.env.example
Normal file
11
deploy/.env.example
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Copy to .env at the repo root and fill in. Used by docker-compose.yml.
|
||||||
|
|
||||||
|
# Postgres
|
||||||
|
POSTGRES_PASSWORD=change-me-strong-password
|
||||||
|
POSTGRES_DB=scoreboardtools
|
||||||
|
|
||||||
|
# Auth — generate with: openssl rand -hex 32
|
||||||
|
JWT_SECRET=replace-with-a-long-random-secret
|
||||||
|
|
||||||
|
# Cookies require HTTPS in production. Keep true behind your TLS reverse proxy.
|
||||||
|
COOKIE_SECURE=true
|
||||||
5
deploy/Caddyfile
Normal file
5
deploy/Caddyfile
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Caddy auto-provisions TLS and transparently proxies WebSockets — simplest option.
|
||||||
|
# Replace the domain, then: caddy run --config deploy/Caddyfile
|
||||||
|
scoreboard.example.com {
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
|
}
|
||||||
36
deploy/nginx.conf
Normal file
36
deploy/nginx.conf
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# nginx server block. Use certbot (Let's Encrypt) for the TLS certificate.
|
||||||
|
# The key detail is the Upgrade/Connection headers so /ws WebSockets pass through.
|
||||||
|
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name scoreboard.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/scoreboard.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/scoreboard.example.com/privkey.pem;
|
||||||
|
|
||||||
|
client_max_body_size 6m; # allow logo uploads
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# WebSocket upgrade (covers /ws)
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_read_timeout 1h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name scoreboard.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
37
docker-compose.yml
Normal file
37
docker-compose.yml
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-scoreboardtools}
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
server:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-scoreboardtools}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env}
|
||||||
|
COOKIE_SECURE: ${COOKIE_SECURE:-true}
|
||||||
|
UPLOAD_DIR: /data/uploads
|
||||||
|
PORT: '3000'
|
||||||
|
volumes:
|
||||||
|
- uploads:/data/uploads
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
# Bind to localhost only — your reverse proxy (nginx/Caddy) terminates TLS and
|
||||||
|
# forwards to this. Drop the 127.0.0.1 prefix only if exposing directly.
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:3000:3000'
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
|
uploads:
|
||||||
176
docs/PLAN.md
Normal file
176
docs/PLAN.md
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
# Scoreboard Tools — SSBU Real-Time Scoreboard PWA
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`scoreboardtools` is a greenfield project (empty repo, just a README). The goal is a
|
||||||
|
self-hosted Progressive Web App for running **Super Smash Bros Ultimate** tournament
|
||||||
|
scoreboards. A user logs in, creates scoreboards from a dashboard, and edits a customizable
|
||||||
|
"scorebug" (logo, two players with characters/stocks/score, subtitles, callouts, side-swap).
|
||||||
|
|
||||||
|
The defining requirement is **real-time**: the scorebug can be opened as a read-only overlay
|
||||||
|
in OBS (transparent background) that updates the instant the score changes, and the
|
||||||
|
scorekeeping can be handed to a second person via a link that requires **no login** and lets
|
||||||
|
them co-edit live. Scoreboards persist so users can return to them.
|
||||||
|
|
||||||
|
**Decisions locked in with the user:**
|
||||||
|
- Backend: **lean custom stack** — Node (Fastify) + Postgres + WebSocket, self-hosted via Docker.
|
||||||
|
- Auth: **email + password** (simplest for self-hosting; no SMTP/OAuth dependency).
|
||||||
|
- Share link: **co-edit** via unguessable token, separate from the read-only overlay link.
|
||||||
|
- Character art: **placeholders first** (full picker UI now, real fighter icons wired in later).
|
||||||
|
- Server: Docker + Compose, a domain with HTTPS/TLS, and a reverse proxy (nginx/Caddy) — deploy
|
||||||
|
steps included since not everything is set up yet.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
Single source of truth lives in the **WebSocket server**, which holds each scoreboard's live
|
||||||
|
state in memory and is the authority all clients sync to.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
Owner editor ──WS──► │ Fastify + ws server │ ──debounced──► Postgres
|
||||||
|
(JWT auth) │ - in-memory room per board │ (durable save)
|
||||||
|
│ - last-write-wins authority │
|
||||||
|
Co-editor ──WS──► │ - broadcasts state to room │ ◄──snapshot on connect/reconnect
|
||||||
|
(control_token) │ │
|
||||||
|
└──────────────┬───────────────┘
|
||||||
|
OBS overlay ──WS──► (read-only) │ broadcasts authoritative state to ALL room members
|
||||||
|
(overlay_token) ▼
|
||||||
|
instant scorebug update
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this is fast AND durable:** edits broadcast through in-memory room state immediately
|
||||||
|
(sub-frame latency for the overlay), while saves to Postgres are **debounced (~750ms)** so we
|
||||||
|
never write per-keystroke. On reconnect/reload, the client gets the latest persisted state,
|
||||||
|
then live deltas. Last-write-wins is acceptable for a 2-person scoreboard.
|
||||||
|
|
||||||
|
## Repo Structure (pnpm workspace — modular & concise)
|
||||||
|
|
||||||
|
```
|
||||||
|
scoreboardtools/
|
||||||
|
apps/
|
||||||
|
web/ React + Vite + TS PWA (dashboard, editor, control, overlay)
|
||||||
|
server/ Fastify HTTP/REST + `ws` WebSocket + Drizzle/Postgres
|
||||||
|
packages/
|
||||||
|
shared/ zod schemas + TS types for ScoreboardState and the WS protocol
|
||||||
|
docker-compose.yml
|
||||||
|
deploy/ Caddyfile + nginx.conf examples, .env.example
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`packages/shared` is the keystone: one zod schema defines the scoreboard state and the
|
||||||
|
WS message protocol, imported by **both** web and server — types can never drift.
|
||||||
|
|
||||||
|
## Data Model (Postgres, via Drizzle ORM)
|
||||||
|
|
||||||
|
- **users**: `id`, `email` (unique), `password_hash` (bcrypt), `created_at`
|
||||||
|
- **scoreboards**: `id`, `owner_id` → users, `name`, `state jsonb`, `overlay_token` (unique),
|
||||||
|
`control_token` (unique), `created_at`, `updated_at`
|
||||||
|
|
||||||
|
`state` is a single JSONB document — the whole scorebug is one small object, ideal for
|
||||||
|
broadcasting and keeps the schema concise:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
ScoreboardState = {
|
||||||
|
eventLogoUrl?: string
|
||||||
|
theme: { accent: string; bg: string } // customizable scorebug look
|
||||||
|
bestOf?: number // e.g. Bo3 / Bo5
|
||||||
|
centerCallout?: string // middle-of-scorebug text
|
||||||
|
players: [Player, Player] // index 0 = left, 1 = right
|
||||||
|
swapped: boolean // side-swap toggle
|
||||||
|
}
|
||||||
|
Player = { name; character: FighterId; stocks: number; score: number; subtitle?: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
Tokens (`overlay_token`, `control_token`) are unguessable (`nanoid`/crypto random) so the
|
||||||
|
public links are the access control for the no-login flows.
|
||||||
|
|
||||||
|
## Backend (`apps/server`)
|
||||||
|
|
||||||
|
- **Fastify** HTTP for REST + static; **`ws`** (raw WebSocket — lighter than Socket.IO, no
|
||||||
|
fallback transports needed) for real-time.
|
||||||
|
- **Auth**: `@fastify/jwt` + `bcrypt`. JWT in an httpOnly cookie. Routes: `POST /api/signup`,
|
||||||
|
`POST /api/login`, `POST /api/logout`, `GET /api/me`.
|
||||||
|
- **Scoreboard REST** (owner, JWT-guarded): `GET/POST /api/scoreboards`,
|
||||||
|
`GET/PATCH/DELETE /api/scoreboards/:id`. Create generates both tokens.
|
||||||
|
- **WebSocket** `GET /ws`: client opens with a credential identifying role —
|
||||||
|
- editor → JWT cookie (read+write, must own the board)
|
||||||
|
- co-editor → `control_token` (read+write)
|
||||||
|
- overlay → `overlay_token` (read-only)
|
||||||
|
Server resolves the board, joins an in-memory **room** (Map of boardId → {state, sockets}),
|
||||||
|
sends a `snapshot`, relays `update` messages from writers to the whole room, and schedules a
|
||||||
|
debounced Postgres save. Read-only sockets that send writes are ignored.
|
||||||
|
- **Logo upload**: `POST /api/uploads` (multipart) saves to a Docker-volume `/uploads` dir,
|
||||||
|
served as static; the returned URL goes into `state.eventLogoUrl`. (Avoids JSONB bloat from base64.)
|
||||||
|
- **Migrations**: Drizzle Kit generates SQL; run on container start.
|
||||||
|
|
||||||
|
### WS message protocol (defined in `packages/shared`)
|
||||||
|
- Client→Server: `{ type: 'update', state }` (writers only)
|
||||||
|
- Server→Client: `{ type: 'snapshot', state }` on join, `{ type: 'state', state }` on every broadcast
|
||||||
|
- Optimization note: start with **full-state** messages (state is a few KB). JSON-merge-patch
|
||||||
|
deltas are a later optimization if needed.
|
||||||
|
|
||||||
|
## Frontend (`apps/web` — React + Vite + TS + Tailwind)
|
||||||
|
|
||||||
|
**Routes** (react-router):
|
||||||
|
- `/login`, `/signup` — auth screens
|
||||||
|
- `/` — **dashboard**: list / create / rename / delete scoreboards; copy overlay & control links (auth required)
|
||||||
|
- `/scoreboard/:id` — **editor** (owner, auth required): live preview + full controls
|
||||||
|
- `/control/:controlToken` — **co-edit control panel**, no login
|
||||||
|
- `/overlay/:overlayToken` — **read-only overlay**, transparent background for OBS
|
||||||
|
|
||||||
|
**The real-time core — one shared hook** `useScoreboardSync(credential, role)`:
|
||||||
|
- Opens the WebSocket, applies `snapshot`/`state` messages to local state, exposes a
|
||||||
|
`sendUpdate(partial)` that optimistically updates locally then pushes to the server.
|
||||||
|
- Auto-reconnect with backoff; re-requests snapshot on reconnect.
|
||||||
|
- Reused identically by editor, control panel, and overlay — only `role`/credential differ.
|
||||||
|
This is what makes the app modular: **one sync primitive, three consumers.**
|
||||||
|
|
||||||
|
**Components** (composed, reused across editor / control / overlay):
|
||||||
|
- `ScoreBug` — the rendered scorebug (the single visual component shown in the editor preview,
|
||||||
|
the OBS overlay, and the control panel preview). Reads `ScoreboardState`, honors `swapped`.
|
||||||
|
- `PlayerPanel` — name, `CharacterPicker` (placeholder grid now, real icons later),
|
||||||
|
`StockControls` (+/−), score `+/−`, subtitle field.
|
||||||
|
- `ScoreControls` — main score, center callout, best-of.
|
||||||
|
- `LogoUploader`, `ThemeControls`, `SwapSidesButton`.
|
||||||
|
- `ShareLinks` — copyable overlay + control URLs (dashboard & editor).
|
||||||
|
|
||||||
|
**PWA**: `vite-plugin-pwa` (manifest + service worker, installable). Scope the service worker
|
||||||
|
to the app shell; keep the `/overlay/*` route network-first / uncached so OBS never shows stale
|
||||||
|
state. Icons + offline shell for the dashboard/editor.
|
||||||
|
|
||||||
|
## Deployment (`docker-compose.yml` + reverse proxy)
|
||||||
|
|
||||||
|
- **Services**: `db` (postgres, volume), `server` (Node; serves REST + WS + built `web` static +
|
||||||
|
`/uploads`). The web app is built and served by the server container to keep it to two
|
||||||
|
long-running services.
|
||||||
|
- **Reverse proxy** (their nginx/Caddy) terminates TLS for the domain and proxies to `server`,
|
||||||
|
**including WebSocket upgrade headers** for `/ws`. Provide both:
|
||||||
|
- `deploy/Caddyfile` (auto-TLS, simplest) and `deploy/nginx.conf` (with `Upgrade`/`Connection` headers).
|
||||||
|
- **Env** (`deploy/.env.example`): `DATABASE_URL`, `JWT_SECRET`, `PUBLIC_URL`, `UPLOAD_DIR`.
|
||||||
|
- TLS is required: OBS browser sources and PWA install both need HTTPS, and the WS must be `wss://`.
|
||||||
|
|
||||||
|
## Suggested Build Phases (incremental, each independently runnable)
|
||||||
|
|
||||||
|
1. **Scaffold**: pnpm workspace, `shared` zod schema + types, Vite React app, Fastify server, Dockerfiles, compose with Postgres. App boots end to end.
|
||||||
|
2. **Auth + dashboard**: signup/login (JWT cookie), scoreboard CRUD, dashboard list/create/delete. Persists to Postgres.
|
||||||
|
3. **Editor + ScoreBug + real-time**: `useScoreboardSync` hook, WS rooms, in-memory authority + debounced save. Editing updates a live preview; the **overlay route updates in real time** (verify with two browser tabs).
|
||||||
|
4. **Sharing**: control-token co-edit panel + overlay-token read-only route, copyable links, no-login access.
|
||||||
|
5. **Customization polish**: logo upload, theme/accent, side-swap, subtitles, center callout, best-of, character picker (placeholder grid).
|
||||||
|
6. **PWA + deploy**: manifest/service worker, install, Caddy/nginx config, ship to the Linux server over the domain, test the overlay live in OBS.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- **Real-time (the critical path)**: open `/scoreboard/:id` (editor) and `/overlay/:token` in two
|
||||||
|
tabs (or editor on desktop + overlay in OBS). Change score/stocks/callout in the editor → the
|
||||||
|
overlay updates within a frame, no refresh. Open `/control/:token` in a private window (logged
|
||||||
|
out) → confirm a non-authenticated co-editor can drive the same board and both editor and
|
||||||
|
overlay reflect it.
|
||||||
|
- **Persistence**: reload the editor and reconnect after stopping/starting the server →
|
||||||
|
scoreboard returns from Postgres with the latest state; dashboard still lists it.
|
||||||
|
- **Auth boundaries**: overlay token cannot write (writes ignored); a user cannot fetch/edit
|
||||||
|
another user's scoreboard via REST; overlay/control links work logged-out.
|
||||||
|
- **Deploy**: `docker compose up` locally brings up db + server; visit over the domain, confirm
|
||||||
|
`wss://` connects through the reverse proxy, install the PWA, and load the overlay as an OBS
|
||||||
|
browser source with a transparent background.
|
||||||
|
- **Tests**: a server unit test for the room broadcast + debounced-save logic and the token
|
||||||
|
access rules (the parts most worth locking down).
|
||||||
19
package.json
Normal file
19
package.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "scoreboardtools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@9.7.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm --parallel -r dev",
|
||||||
|
"dev:web": "pnpm --filter @sbt/web dev",
|
||||||
|
"dev:server": "pnpm --filter @sbt/server dev",
|
||||||
|
"build": "pnpm --filter @sbt/web build",
|
||||||
|
"start": "pnpm --filter @sbt/server start",
|
||||||
|
"test": "pnpm -r test",
|
||||||
|
"typecheck": "pnpm -r typecheck"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
22
packages/shared/package.json
Normal file
22
packages/shared/package.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@sbt/shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^2.0.5",
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
packages/shared/src/fighters.ts
Normal file
27
packages/shared/src/fighters.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
// Super Smash Bros. Ultimate fighter roster (display names used as FighterId).
|
||||||
|
// Art is intentionally NOT bundled yet — the picker renders placeholder chips
|
||||||
|
// derived from these names. Swap in real stock icons later by mapping id -> image.
|
||||||
|
export const FIGHTERS = [
|
||||||
|
'Mario', 'Donkey Kong', 'Link', 'Samus', 'Dark Samus', 'Yoshi', 'Kirby',
|
||||||
|
'Fox', 'Pikachu', 'Luigi', 'Ness', 'Captain Falcon', 'Jigglypuff', 'Peach',
|
||||||
|
'Daisy', 'Bowser', 'Ice Climbers', 'Sheik', 'Zelda', 'Dr. Mario', 'Pichu',
|
||||||
|
'Falco', 'Marth', 'Lucina', 'Young Link', 'Ganondorf', 'Mewtwo', 'Roy',
|
||||||
|
'Chrom', 'Mr. Game & Watch', 'Meta Knight', 'Pit', 'Dark Pit', 'Zero Suit Samus',
|
||||||
|
'Wario', 'Snake', 'Ike', 'Pokemon Trainer', 'Diddy Kong', 'Lucas', 'Sonic',
|
||||||
|
'King Dedede', 'Olimar', 'Lucario', 'R.O.B.', 'Toon Link', 'Wolf', 'Villager',
|
||||||
|
'Mega Man', 'Wii Fit Trainer', 'Rosalina & Luma', 'Little Mac', 'Greninja',
|
||||||
|
'Mii Brawler', 'Mii Swordfighter', 'Mii Gunner', 'Palutena', 'Pac-Man',
|
||||||
|
'Robin', 'Shulk', 'Bowser Jr.', 'Duck Hunt', 'Ryu', 'Ken', 'Cloud',
|
||||||
|
'Corrin', 'Bayonetta', 'Inkling', 'Ridley', 'Simon', 'Richter', 'King K. Rool',
|
||||||
|
'Isabelle', 'Incineroar', 'Piranha Plant', 'Joker', 'Hero', 'Banjo & Kazooie',
|
||||||
|
'Terry', 'Byleth', 'Min Min', 'Steve', 'Sephiroth', 'Pyra/Mythra', 'Kazuya', 'Sora',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type FighterId = (typeof FIGHTERS)[number] | '';
|
||||||
|
|
||||||
|
export function fighterInitials(id: string): string {
|
||||||
|
if (!id) return '?';
|
||||||
|
const words = id.replace(/[^a-zA-Z0-9 &.]/g, '').split(/\s+/).filter(Boolean);
|
||||||
|
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
|
||||||
|
return (words[0][0] + words[1][0]).toUpperCase();
|
||||||
|
}
|
||||||
3
packages/shared/src/index.ts
Normal file
3
packages/shared/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './state';
|
||||||
|
export * from './protocol';
|
||||||
|
export * from './fighters';
|
||||||
26
packages/shared/src/protocol.ts
Normal file
26
packages/shared/src/protocol.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { ScoreboardStateSchema, type ScoreboardState } from './state';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket wire protocol. One source of truth shared by client and server.
|
||||||
|
*
|
||||||
|
* Connection roles (resolved server-side from the connect query string):
|
||||||
|
* - editor: owner (?board=<id>, authed via cookie) OR co-editor (?control=<token>)
|
||||||
|
* - viewer: OBS overlay (?overlay=<token>), read-only
|
||||||
|
*/
|
||||||
|
export type Role = 'editor' | 'viewer';
|
||||||
|
|
||||||
|
/** Client -> server. Only honored from editor connections. */
|
||||||
|
export const ClientMessageSchema = z.object({
|
||||||
|
type: z.literal('update'),
|
||||||
|
state: ScoreboardStateSchema,
|
||||||
|
});
|
||||||
|
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
||||||
|
|
||||||
|
/** Server -> client. */
|
||||||
|
export type ServerMessage =
|
||||||
|
| { type: 'snapshot'; state: ScoreboardState } // sent once on connect
|
||||||
|
| { type: 'state'; state: ScoreboardState } // broadcast on every change
|
||||||
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
|
export const WS_PATH = '/ws';
|
||||||
31
packages/shared/src/state.test.ts
Normal file
31
packages/shared/src/state.test.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { defaultState, setPlayer, ScoreboardStateSchema, clamp } from './state';
|
||||||
|
import { ClientMessageSchema } from './protocol';
|
||||||
|
|
||||||
|
describe('scoreboard state', () => {
|
||||||
|
it('produces a valid default state', () => {
|
||||||
|
const s = defaultState();
|
||||||
|
expect(ScoreboardStateSchema.safeParse(s).success).toBe(true);
|
||||||
|
expect(s.players).toHaveLength(2);
|
||||||
|
expect(s.players[0].name).toBe('Player 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('patches a single player immutably', () => {
|
||||||
|
const s = defaultState();
|
||||||
|
const next = setPlayer(s, 1, { score: 2 });
|
||||||
|
expect(next.players[1].score).toBe(2);
|
||||||
|
expect(s.players[1].score).toBe(0); // original untouched
|
||||||
|
expect(next.players[0]).toBe(s.players[0]); // other slot shared
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps out-of-range values', () => {
|
||||||
|
expect(clamp(-1, 0, 9)).toBe(0);
|
||||||
|
expect(clamp(99, 0, 9)).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates an update message and rejects garbage', () => {
|
||||||
|
const ok = ClientMessageSchema.safeParse({ type: 'update', state: defaultState() });
|
||||||
|
expect(ok.success).toBe(true);
|
||||||
|
expect(ClientMessageSchema.safeParse({ type: 'update', state: { players: [] } }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
packages/shared/src/state.ts
Normal file
55
packages/shared/src/state.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entire scorebug is a single small JSON document. Keeping it as one schema
|
||||||
|
* makes real-time sync trivial (broadcast the whole state) and persistence simple
|
||||||
|
* (one jsonb column). Both the server and the web app validate against this.
|
||||||
|
*/
|
||||||
|
export const PlayerSchema = z.object({
|
||||||
|
name: z.string().max(40).default('Player'),
|
||||||
|
character: z.string().max(40).default(''), // FighterId, '' = unset
|
||||||
|
stocks: z.number().int().min(0).max(9).default(3),
|
||||||
|
score: z.number().int().min(0).max(99).default(0),
|
||||||
|
subtitle: z.string().max(60).default(''), // callout under the player name
|
||||||
|
});
|
||||||
|
export type Player = z.infer<typeof PlayerSchema>;
|
||||||
|
|
||||||
|
export const ThemeSchema = z.object({
|
||||||
|
accent: z.string().max(32).default('#e63946'),
|
||||||
|
bg: z.string().max(32).default('#0d1b2a'),
|
||||||
|
});
|
||||||
|
export type Theme = z.infer<typeof ThemeSchema>;
|
||||||
|
|
||||||
|
export const ScoreboardStateSchema = z.object({
|
||||||
|
eventLogoUrl: z.string().max(500).default(''),
|
||||||
|
theme: ThemeSchema.default({}),
|
||||||
|
bestOf: z.number().int().min(0).max(99).default(5), // 0 = hide
|
||||||
|
centerCallout: z.string().max(80).default(''), // middle-of-scorebug text
|
||||||
|
swapped: z.boolean().default(false), // swap which player is on the left
|
||||||
|
players: z.tuple([PlayerSchema, PlayerSchema]),
|
||||||
|
});
|
||||||
|
export type ScoreboardState = z.infer<typeof ScoreboardStateSchema>;
|
||||||
|
|
||||||
|
export function defaultState(): ScoreboardState {
|
||||||
|
return ScoreboardStateSchema.parse({
|
||||||
|
players: [
|
||||||
|
PlayerSchema.parse({ name: 'Player 1' }),
|
||||||
|
PlayerSchema.parse({ name: 'Player 2' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Immutable helper: patch one player slot, returning a new state. */
|
||||||
|
export function setPlayer(
|
||||||
|
state: ScoreboardState,
|
||||||
|
index: 0 | 1,
|
||||||
|
patch: Partial<Player>,
|
||||||
|
): ScoreboardState {
|
||||||
|
const players = [...state.players] as [Player, Player];
|
||||||
|
players[index] = { ...players[index], ...patch };
|
||||||
|
return { ...state, players };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp helper so +/- controls never push values out of the valid range. */
|
||||||
|
export const clamp = (n: number, min: number, max: number) =>
|
||||||
|
Math.max(min, Math.min(max, n));
|
||||||
8
packages/shared/tsconfig.json
Normal file
8
packages/shared/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
6064
pnpm-lock.yaml
generated
Normal file
6064
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- apps/*
|
||||||
|
- packages/*
|
||||||
16
tsconfig.base.json
Normal file
16
tsconfig.base.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user