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>
37 lines
1.5 KiB
TypeScript
37 lines
1.5 KiB
TypeScript
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>
|
|
);
|
|
}
|