Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions packages/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,42 @@
import { } from "react";
import { useEffect, useState } from "react";
import Scoreboard from "./Scoreboard";

export default function App() {
const [tournamentId, setTournamentId] = useState<string>("");

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 (
<div className="min-h-screen bg-gradient-to-br from-purple-600 via-pink-500 to-orange-400 text-white flex items-center justify-center p-4">
<div className="text-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-white mx-auto mb-4"></div>
<p className="text-xl">Creating tournament...</p>
</div>
</div>
);
}

return (
<div className="min-h-screen bg-gradient-to-br from-purple-600 via-pink-500 to-orange-400 text-white flex items-center justify-center p-4">
<Scoreboard />
<Scoreboard tournamentId={tournamentId} />
</div>
);
}

function generateTournamentId(): string {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
14 changes: 8 additions & 6 deletions packages/web/src/Scoreboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Lexer,
Parser,
} from "../../lang/src/index";
import { useTournamentState } from "./hooks/useTournamentState";

type Team = "🩷 Team A" | "⚪ Team B" | "⚫ Team C";

Expand All @@ -27,12 +28,13 @@ const TEAM_MAP: Record<Team, string> = {
"⚫ Team C": "TeamC",
};

export default function Scoreboard() {
const [games, setGames] = useState<Game[]>([
{ home: TEAMS[0]!, away: TEAMS[1]!, homeScore: 0, awayScore: 0 },
]);
// Tournament state as scorelang text
const [tournamentText, setTournamentText] = useState<string>("");
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"
Expand Down
180 changes: 180 additions & 0 deletions packages/web/src/hooks/useTournamentState.ts
Original file line number Diff line number Diff line change
@@ -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<Game[]>([
{ home: "🩷 Team A", away: "⚪ Team B", homeScore: 0, awayScore: 0 },
]);
const [tournamentText, setTournamentText] = useState<string>("");
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<TournamentState> = {};
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,
};
}
73 changes: 73 additions & 0 deletions packages/web/src/tournament-durable-object.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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<Response> {
const games = await this.state.storage.get<TournamentState['games']>('games') ?? [];
const tournamentText = await this.state.storage.get<string>('tournamentText') ?? '';

const state: TournamentState = {
games,
tournamentText
};

return new Response(JSON.stringify(state), {
headers: { 'Content-Type': 'application/json' }
});
}

private async handlePost(request: Request): Promise<Response> {
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<Response> {
const data = await request.json() as Partial<TournamentState>;

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');
}
}
16 changes: 16 additions & 0 deletions packages/web/src/worker.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import { TournamentDurableObject } from './tournament-durable-object';

export { TournamentDurableObject };

export default {
async fetch(request: Request, env: any): Promise<Response> {
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
Expand Down
8 changes: 8 additions & 0 deletions packages/web/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,13 @@
"directory": "./dist",
"binding": "ASSETS"
},
"durable_objects": {
"bindings": [
{
"name": "TOURNAMENT_DO",
"class_name": "TournamentDurableObject"
}
]
},
"vars": {}
}
Loading