diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 229dc0f..7731b6b 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,10 +1,42 @@ -import { } from "react"; +import { useEffect, useState } from "react"; import Scoreboard from "./Scoreboard"; export default function App() { + const [tournamentId, setTournamentId] = useState(""); + + useEffect(() => { + // Get tournament ID from URL or generate a new one + const params = new URLSearchParams(window.location.search); + let id = params.get('tournament'); + + if (!id) { + // Generate a new tournament ID and update URL + id = generateTournamentId(); + const newUrl = `${window.location.pathname}?tournament=${id}`; + window.history.replaceState({}, '', newUrl); + } + + setTournamentId(id); + }, []); + + if (!tournamentId) { + return ( +
+
+
+

Creating tournament...

+
+
+ ); + } + return (
- +
); } + +function generateTournamentId(): string { + return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); +} diff --git a/packages/web/src/Scoreboard.tsx b/packages/web/src/Scoreboard.tsx index 037def9..52a7e38 100644 --- a/packages/web/src/Scoreboard.tsx +++ b/packages/web/src/Scoreboard.tsx @@ -8,6 +8,7 @@ import { Lexer, Parser, } from "../../lang/src/index"; +import { useTournamentState } from "./hooks/useTournamentState"; type Team = "🩷 Team A" | "⚪ Team B" | "⚫ Team C"; @@ -27,12 +28,13 @@ const TEAM_MAP: Record = { "⚫ Team C": "TeamC", }; -export default function Scoreboard() { - const [games, setGames] = useState([ - { home: TEAMS[0]!, away: TEAMS[1]!, homeScore: 0, awayScore: 0 }, - ]); - // Tournament state as scorelang text - const [tournamentText, setTournamentText] = useState(""); +interface ScoreboardProps { + tournamentId: string; +} + +export default function Scoreboard({ tournamentId }: ScoreboardProps) { + const { games, tournamentText, isLoading, setGames, setTournamentText } = useTournamentState(tournamentId); + // Active tab state const [activeTab, setActiveTab] = useState<"score" | "scorelang" | "table">( "score" diff --git a/packages/web/src/hooks/useTournamentState.ts b/packages/web/src/hooks/useTournamentState.ts new file mode 100644 index 0000000..5a03b19 --- /dev/null +++ b/packages/web/src/hooks/useTournamentState.ts @@ -0,0 +1,180 @@ +import { useState, useEffect, useCallback } from "react"; + +type Team = "🩷 Team A" | "⚪ Team B" | "⚫ Team C"; + +type Game = { + home: Team; + away: Team; + homeScore: number; + awayScore: number; +}; + +interface TournamentState { + games: Game[]; + tournamentText: string; +} + +export function useTournamentState(tournamentId: string) { + const [games, setGames] = useState([ + { home: "🩷 Team A", away: "⚪ Team B", homeScore: 0, awayScore: 0 }, + ]); + const [tournamentText, setTournamentText] = useState(""); + const [isLoading, setIsLoading] = useState(true); + + // Load state from durable object or localStorage (fallback for development) + useEffect(() => { + const loadTournamentState = async () => { + try { + const response = await fetch(`/api/tournament/${tournamentId}`); + if ( + response.ok && + response.headers.get("content-type")?.includes("application/json") + ) { + const state: TournamentState = await response.json(); + if (state.games.length > 0) { + setGames(state.games); + } + if (state.tournamentText) { + setTournamentText(state.tournamentText); + } + } else { + // Fallback to localStorage for development + const localState = localStorage.getItem(`tournament_${tournamentId}`); + if (localState) { + const state: TournamentState = JSON.parse(localState); + if (state.games.length > 0) { + setGames(state.games); + } + if (state.tournamentText) { + setTournamentText(state.tournamentText); + } + } + } + } catch (error) { + console.error("Failed to load tournament state:", error); + // Try localStorage as fallback + try { + const localState = localStorage.getItem(`tournament_${tournamentId}`); + if (localState) { + const state: TournamentState = JSON.parse(localState); + if (state.games.length > 0) { + setGames(state.games); + } + if (state.tournamentText) { + setTournamentText(state.tournamentText); + } + } + } catch (localError) { + console.error("Failed to load from localStorage:", localError); + } + } finally { + setIsLoading(false); + } + }; + + loadTournamentState(); + }, [tournamentId]); + + // Save state to durable object or localStorage (fallback for development) + const saveState = useCallback( + async (newGames?: Game[], newTournamentText?: string) => { + try { + const updateData: Partial = {}; + if (newGames !== undefined) { + updateData.games = newGames; + } + if (newTournamentText !== undefined) { + updateData.tournamentText = newTournamentText; + } + + const response = await fetch(`/api/tournament/${tournamentId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updateData), + }); + + if ( + !response.ok || + !response.headers.get("content-type")?.includes("application/json") + ) { + // Fallback to localStorage for development + const currentState = localStorage.getItem( + `tournament_${tournamentId}` + ); + const fullState: TournamentState = currentState + ? JSON.parse(currentState) + : { games: [], tournamentText: "" }; + + if (newGames !== undefined) { + fullState.games = newGames; + } + if (newTournamentText !== undefined) { + fullState.tournamentText = newTournamentText; + } + + localStorage.setItem( + `tournament_${tournamentId}`, + JSON.stringify(fullState) + ); + } + } catch (error) { + console.error("Failed to save tournament state:", error); + // Fallback to localStorage + try { + const currentState = localStorage.getItem( + `tournament_${tournamentId}` + ); + const fullState: TournamentState = currentState + ? JSON.parse(currentState) + : { games: [], tournamentText: "" }; + + if (newGames !== undefined) { + fullState.games = newGames; + } + if (newTournamentText !== undefined) { + fullState.tournamentText = newTournamentText; + } + + localStorage.setItem( + `tournament_${tournamentId}`, + JSON.stringify(fullState) + ); + } catch (localError) { + console.error("Failed to save to localStorage:", localError); + } + } + }, + [tournamentId] + ); + + // Enhanced setters that also save to durable object + const updateGames = useCallback( + (updater: (prev: Game[]) => Game[]) => { + setGames((prev) => { + const newGames = updater(prev); + saveState(newGames, undefined); + return newGames; + }); + }, + [saveState] + ); + + const updateTournamentText = useCallback( + (updater: (prev: string) => string) => { + setTournamentText((prev) => { + const newText = updater(prev); + saveState(undefined, newText); + return newText; + }); + }, + [saveState] + ); + + return { + games, + tournamentText, + isLoading, + setGames: updateGames, + setTournamentText: updateTournamentText, + }; +} diff --git a/packages/web/src/tournament-durable-object.ts b/packages/web/src/tournament-durable-object.ts new file mode 100644 index 0000000..e5deb3e --- /dev/null +++ b/packages/web/src/tournament-durable-object.ts @@ -0,0 +1,73 @@ +export interface DurableObjectState { + storage: DurableObjectStorage; +} + +export interface TournamentState { + games: Array<{ + home: string; + away: string; + homeScore: number; + awayScore: number; + }>; + tournamentText: string; +} + +export class TournamentDurableObject { + private state: DurableObjectState; + + constructor(state: DurableObjectState) { + this.state = state; + } + + async fetch(request: Request): Promise { + const url = new URL(request.url); + + switch (request.method) { + case 'GET': + return this.handleGet(); + case 'POST': + return this.handlePost(request); + case 'PUT': + return this.handlePut(request); + default: + return new Response('Method not allowed', { status: 405 }); + } + } + + private async handleGet(): Promise { + const games = await this.state.storage.get('games') ?? []; + const tournamentText = await this.state.storage.get('tournamentText') ?? ''; + + const state: TournamentState = { + games, + tournamentText + }; + + return new Response(JSON.stringify(state), { + headers: { 'Content-Type': 'application/json' } + }); + } + + private async handlePost(request: Request): Promise { + const data = await request.json() as TournamentState; + + await this.state.storage.put('games', data.games); + await this.state.storage.put('tournamentText', data.tournamentText); + + return new Response('OK'); + } + + private async handlePut(request: Request): Promise { + const data = await request.json() as Partial; + + if (data.games !== undefined) { + await this.state.storage.put('games', data.games); + } + + if (data.tournamentText !== undefined) { + await this.state.storage.put('tournamentText', data.tournamentText); + } + + return new Response('OK'); + } +} diff --git a/packages/web/src/worker.tsx b/packages/web/src/worker.tsx index 2509802..adbbe02 100644 --- a/packages/web/src/worker.tsx +++ b/packages/web/src/worker.tsx @@ -1,7 +1,23 @@ +import { TournamentDurableObject } from './tournament-durable-object'; + +export { TournamentDurableObject }; + export default { async fetch(request: Request, env: any): Promise { const url = new URL(request.url); + // Handle API routes for tournament state + if (url.pathname.startsWith('/api/tournament/')) { + const tournamentId = url.pathname.split('/api/tournament/')[1]; + if (!tournamentId) { + return new Response('Tournament ID required', { status: 400 }); + } + + const durableObjectId = env.TOURNAMENT_DO.idFromName(tournamentId); + const durableObject = env.TOURNAMENT_DO.get(durableObjectId); + return durableObject.fetch(request); + } + // Serve static files from the public directory if (url.pathname.startsWith('/assets/') || url.pathname.endsWith('.css') || url.pathname.endsWith('.js')) { // Let Cloudflare serve static assets diff --git a/packages/web/wrangler.jsonc b/packages/web/wrangler.jsonc index 08dfb09..17533a4 100644 --- a/packages/web/wrangler.jsonc +++ b/packages/web/wrangler.jsonc @@ -17,5 +17,13 @@ "directory": "./dist", "binding": "ASSETS" }, + "durable_objects": { + "bindings": [ + { + "name": "TOURNAMENT_DO", + "class_name": "TournamentDurableObject" + } + ] + }, "vars": {} }