Compare commits

...

2 Commits

Author SHA1 Message Date
corphish-assistant
c1cf95e855 working 2026-06-27 04:46:51 +00:00
Ashraf Shafiq
9169bea79f Scaffold real-time SSBU scoreboard PWA
pnpm monorepo with three workspaces:
- @sbt/shared: zod ScoreboardState + WebSocket protocol (single source of truth)
- @sbt/server: Fastify REST + raw ws WebSocket + Drizzle/Postgres, run via tsx
- @sbt/web: React + Vite + Tailwind installable PWA

Real-time core: the WebSocket server holds authoritative per-board state in
memory, broadcasts to all clients (editors + OBS overlays) instantly, and
debounces Postgres saves (~750ms). One useScoreboardSync hook powers the editor,
the no-login co-edit control page, and the read-only OBS overlay.

Includes email+password auth (JWT cookie), scoreboard CRUD, logo upload,
customizable scorebug (characters/stocks/score/subtitles/callout/side-swap/theme),
Docker Compose + Caddy/nginx deploy configs, and docs/PLAN.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:09:27 -04:00
58 changed files with 8402 additions and 2 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
**/node_modules
**/dist
.git
*.log
uploads
data
.env
.DS_Store

11
.gitignore vendored Normal file
View 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
View 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
View 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"]

View File

@ -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
View 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
View 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
};

View 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()
)`;
}

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

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

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

View 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
View 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
View 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())];
}),
);
}

View 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
View 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
View 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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

12
apps/web/public/icon.svg Normal file
View 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">00</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
View 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
View 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;
}

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

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

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

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

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

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

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

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

View 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
View 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
View 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>,
);

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

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

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

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

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

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

48
docker-compose.yml Normal file
View File

@ -0,0 +1,48 @@
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 — kept for local debugging on the host. The reverse proxy
# (Nginx Proxy Manager) reaches this over the shared `proxy` network by container name,
# so no public host port is needed.
ports:
- '127.0.0.1:3000:3000'
networks:
- default
- proxy
restart: unless-stopped
volumes:
db-data:
uploads:
networks:
# NPM lives on this network (its compose project's default network). Joining it lets
# NPM proxy to `scoreboardtools-server-1:3000` directly. External = managed elsewhere.
proxy:
external: true
name: plex_default

176
docs/PLAN.md Normal file
View 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
View 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"
}
}

View 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"
}
}

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

View File

@ -0,0 +1,3 @@
export * from './state';
export * from './protocol';
export * from './fighters';

View 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';

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

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

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"types": []
},
"include": ["src"]
}

6064
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
packages:
- apps/*
- packages/*

16
tsconfig.base.json Normal file
View 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
}
}