diff --git a/src/app/draft/[id]/overlay/page.tsx b/src/app/draft/[id]/overlay/page.tsx new file mode 100644 index 0000000..fae4d80 --- /dev/null +++ b/src/app/draft/[id]/overlay/page.tsx @@ -0,0 +1,23 @@ +import { notFound } from "next/navigation"; +import { buildDraftState } from "@/lib/draft-data"; +import { getLeagueData } from "@/lib/league-data"; +import { DraftOverlayClient } from "@/components/draft/DraftOverlayClient"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return { title: `Draft Overlay: ${id} – SAL` }; +} + +export default async function DraftOverlayPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const [state, { orgs, players }] = await Promise.all([ + buildDraftState(id), + getLeagueData(), + ]); + + if (!state) notFound(); + + return ; +} diff --git a/src/components/admin/AdminDraftRoomClient.tsx b/src/components/admin/AdminDraftRoomClient.tsx index 5474e6f..31e63e9 100644 --- a/src/components/admin/AdminDraftRoomClient.tsx +++ b/src/components/admin/AdminDraftRoomClient.tsx @@ -198,6 +198,15 @@ export function AdminDraftRoomClient({ state, orgs, players }: { )} + {/* Caster / spectator overlay */} +
+

Caster Overlay

+

Read-only full-screen view for casting or spectating — no login required.

+

+ {typeof window !== "undefined" ? `${window.location.origin}/draft/${room.id}/overlay` : `/draft/${room.id}/overlay`} +

+
+ {/* Captain tokens */}

Captain Tokens

diff --git a/src/components/draft/DraftOverlayClient.tsx b/src/components/draft/DraftOverlayClient.tsx new file mode 100644 index 0000000..b6d698f --- /dev/null +++ b/src/components/draft/DraftOverlayClient.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { DraftState } from "@/types/draft"; +import type { LeaguePlayer, Org } from "@/types/league"; +import { formatDraftTeamLabel } from "@/lib/draft-team"; +import { cn } from "@/lib/utils"; + +const POLL_INTERVAL_MS = 3000; + +const DIVISION_LABELS: Record = { solar: "Solar", lunar: "Lunar", terra: "Terra" }; + +const STATUS_LABELS: Record = { + pending: "Draft not started", + active: "Draft in progress", + paused: "Draft paused", + complete: "Draft complete", +}; + +interface Props { + initialState: DraftState; + orgs: Org[]; + players: LeaguePlayer[]; + draftId: string; +} + +export function DraftOverlayClient({ initialState, orgs, players, draftId }: Props) { + const [state, setState] = useState(initialState); + const [connected, setConnected] = useState(true); + const pollRef = useRef | null>(null); + + const { room, picks, pickSequence, currentOrgId, totalPicks, secondsRemaining } = state; + + // A slot before currentPickIndex with no recorded pick was auto-skipped + // (timer expired with an empty shortlist). + const pickByNumber = new Map(picks.map((p) => [p.pickNumber, p])); + const pickLog: ({ kind: "pick"; pick: (typeof picks)[number] } | { kind: "skip"; pickNumber: number; orgId: string })[] = []; + for (let n = 1; n <= Math.min(room.currentPickIndex, pickSequence.length); n++) { + const pick = pickByNumber.get(n); + if (pick) pickLog.push({ kind: "pick", pick }); + else pickLog.push({ kind: "skip", pickNumber: n, orgId: pickSequence[n - 1]! }); + } + + const poll = useCallback(async () => { + try { + const res = await fetch(`/api/draft/${draftId}`); + if (!res.ok) { setConnected(false); return; } + const data = await res.json() as { state: DraftState }; + setState(data.state); + setConnected(true); + } catch { + setConnected(false); + } + }, [draftId]); + + useEffect(() => { + pollRef.current = setInterval(poll, POLL_INTERVAL_MS); + return () => { if (pollRef.current) clearInterval(pollRef.current); }; + }, [poll]); + + const getOrg = (id: string) => orgs.find((o) => o.id === id && o.divisionId === room.divisionId); + const getTeamLabel = (id: string) => { + const org = getOrg(id); + return org ? formatDraftTeamLabel(org.name, room.divisionId) : "Unknown team"; + }; + const getPlayer = (id: string) => players.find((p) => p.id === id); + const pickedIds = new Set(picks.map((p) => p.playerId)); + const availablePlayers = players.filter((p) => p.divisionId === room.divisionId && !pickedIds.has(p.id)); + + return ( +
+
+
+
+

+ SAL · {DIVISION_LABELS[room.divisionId] ?? room.divisionId} Division Draft +

+

{STATUS_LABELS[room.status]}

+
+
+ {!connected && Reconnecting…} + Pick {Math.min(room.currentPickIndex + 1, totalPicks)} / {totalPicks} +
+
+
+ +
+ {room.status === "active" && currentOrgId && ( +
+

On the Clock

+

{getTeamLabel(currentOrgId)}

+ {secondsRemaining !== null && ( +

+ {Math.floor(secondsRemaining)}s +

+ )} +
+ )} + + {room.status === "complete" && ( +
+

Draft Complete

+
+ )} + + {room.status === "pending" && ( +
+

Waiting to start…

+
+ )} + + {room.status === "paused" && ( +
+

Draft paused

+
+ )} + +
+
+

Up Next

+ {room.status === "active" && pickSequence.length > 0 ? ( +
+ {pickSequence.slice(room.currentPickIndex, room.currentPickIndex + 6).map((orgId, i) => ( +
+ {room.currentPickIndex + i + 1}. + {getTeamLabel(orgId)} +
+ ))} +
+ ) : ( +

+ )} +
+ +
+

Picks ({picks.length})

+ {pickLog.length === 0 ? ( +

No picks yet.

+ ) : ( +
+ {[...pickLog].reverse().map((entry) => + entry.kind === "pick" ? ( +
+ {entry.pick.pickNumber} + {getTeamLabel(entry.pick.orgId)} + + + {getPlayer(entry.pick.playerId)?.displayAlias ?? getPlayer(entry.pick.playerId)?.ign ?? entry.pick.playerId} + +
+ ) : ( +
+ {entry.pickNumber} + {getTeamLabel(entry.orgId)} + + Skipped +
+ ), + )} +
+ )} +
+ +
+

+ Available ({availablePlayers.length}) +

+ {availablePlayers.length === 0 ? ( +

No players remaining.

+ ) : ( +
+ {availablePlayers.map((p) => ( +
+ {p.displayAlias ?? p.ign} + {p.primaryRole} +
+ ))} +
+ )} +
+
+
+
+ ); +}