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>
65 lines
2.2 KiB
TypeScript
65 lines
2.2 KiB
TypeScript
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>
|
|
);
|
|
}
|