Compare commits

..

4 Commits

Author SHA1 Message Date
aidan 8916717a8a Update README.md 2025-07-25 15:42:27 -04:00
Aidan Haas 66186eb016 add clock screensaver, basic team ordering, connection error message 2025-07-24 19:48:00 -04:00
Aidan Haas 8a14be321e add logic for screensaver 2025-07-10 15:19:28 -04:00
Aidan Haas 5032b3c7d1 polished homepage, added game info table 2025-07-10 14:49:49 -04:00
13 changed files with 387 additions and 127 deletions
+22 -2
View File
@@ -1,4 +1,24 @@
# Pool Tracker
# Full Stack Pool Tracker
Simple web app the keeps track of who's turn it is in pool.
Add the players with the homepage, and view who's turn it is on the `/live` page
Uses Next.JS for frontend and Flask for the backend.
Syncs in realtime between devices with sockets
Add the players with the homepage, and view who's turn it is on the `/live` page
![Idle Screen](image.png)
Idle Screensaver ()
![No Connection](image-1.png)
No Connection Message
![Main Menu](image-2.png)
Root Menu
![Tablet Screen](image-3.png)
Live page
Tools used:
SVGs generated from https://www.svgrepo.com
https://www.freetool.dev/emoji-picker/
+32 -7
View File
@@ -7,7 +7,7 @@ from flask_cors import CORS
from flask_socketio import SocketIO
app = Flask(__name__)
CORS(app, origins=["http://localhost:3000", "10.*"])
CORS(app, origins=["*", "0.0.0.0"])
socketio = SocketIO(app, cors_allowed_origins="*")
@@ -38,11 +38,10 @@ def add_player():
@app.route("/reset", methods=["GET"])
def reset():
game_state = {
"gameActive": False,
"players": [""],
"playerTurn": 0
}
game_state["gameActive"] = False
game_state["players"] = []
game_state["playerTurn"] = 0
socketio.emit("player_update", {"players": game_state["players"]})
return jsonify(game_state)
@@ -50,6 +49,32 @@ def reset():
def status():
return jsonify(game_state)
def orderplayers():
stripes = []
solids = []
for player in game_state["players"]:
if player["group"] == "stripes":
stripes.append(player)
else:
solids.append(player)
print(stripes)
print(solids)
combined = [player for pair in zip(stripes, solids) for player in pair]
print(combined)
game_state["players"] = combined
@app.route("/start", methods=["GET"])
def start_game():
players = game_state["players"]
game_state["gameActive"] = True
orderplayers()
current_player = players[game_state["playerTurn"]]
socketio.emit("player_update", {"nextPlayer": current_player})
return jsonify(game_state)
@app.route("/next", methods=["GET"])
def next_player():
players = game_state["players"]
@@ -67,4 +92,4 @@ def next_player():
})
if __name__ == "__main__":
socketio.run(app, host="localhost", port=8080)
socketio.run(app, host="0.0.0.0", port=8080)
+87 -22
View File
@@ -3,11 +3,26 @@
import { useEffect, useState } from 'react';
import io from 'socket.io-client';
const socket = io('http://localhost:8080');
export default function HomePage() {
let baseUrl = "";
if (typeof window !== "undefined") {
baseUrl = `${window.location.protocol}//${window.location.hostname}`;
}
const backendUrl = `${baseUrl}:${process.env.NEXT_PUBLIC_API_BASE}`;
const socket = io(`${backendUrl}`);
const [time, setTime] = useState(new Date());
const [currentPlayer, setCurrentPlayer] = useState<string | null>(null);
const [gameStatus, setGameStatus] = useState<{ gameActive: boolean, players: any[] }>({
gameActive: false,
players: [],
});
useEffect(() => {
socket.on('connect', () => {
console.log('Connected to Socket.IO server');
@@ -15,37 +30,87 @@ export default function HomePage() {
socket.on('player_update', (data: { nextPlayer: string }) => {
setCurrentPlayer(data.nextPlayer);
fetchStatus();
});
return () => {
socket.off('player_update');
};
}, []);
// Idle clock stuff
useEffect(() => {
const interval = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(interval);
}, []);
const timeString = time.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
});
const dateString = time.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
});
const day = time.getDate();
const suffix =
day % 10 === 1 && day !== 11 ? 'st' :
day % 10 === 2 && day !== 12 ? 'nd' :
day % 10 === 3 && day !== 13 ? 'rd' : 'th';
const formattedDate = `${time.toLocaleString('en-US', { month: 'long' })} ${day}${suffix}, ${time.getFullYear()}`;
const advanceTurn = async () => {
await fetch('http://localhost:8080/next'); // triggers backend to emit event
await fetch(`${baseUrl}/next`); // triggers backend to emit event
};
return (
<main className="p-6">
<h1 className="text-2xl font-bold mb-4">Game Turn Tracker</h1>
const fetchStatus = async () => {
try {
const res = await fetch(`${backendUrl}/status`);
const data = await res.json();
setGameStatus(data);
} catch (err) {
console.error("Failed to fetch status", err);
}
};
<div className="mb-4">
{currentPlayer ? (
<p className="text-lg">
🎯 Current Player: <strong>{currentPlayer.name} ({currentPlayer.group})</strong>
</p>
) : (
<p className="text-gray-500">Waiting for turn to start</p>
)}
</div>
if (gameStatus.gameActive) {
return (
<main className="p-6">
<h1 className="text-2xl font-bold mb-4">Game Turn Tracker</h1>
<div className="mb-4">
{currentPlayer ? (
<p className="text-lg">
🎯 Current Player: <strong>{currentPlayer.name} ({currentPlayer.group})</strong>
</p>
) : (
<p className="text-gray-500">Waiting for turn to start</p>
)}
</div>
<button
onClick={advanceTurn}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Next Player
</button>
</main>
);
} else {
return (
<main>
<div className="h-screen flex flex-col items-center justify-center text-center text-4xl font-semibold">
<div>{timeString}</div>
<div className="text-2xl mt-2">{formattedDate}</div>
</div>
<div className="w-full py-4 text-center text-gray-500">
<div className="text-xl mt-2">Go to {baseUrl} to start a game!</div>
</div>
</main>
);
}
<button
onClick={advanceTurn}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Next Player
</button>
</main>
);
}
+216 -95
View File
@@ -1,112 +1,233 @@
'use client';
import { connect } from "http2";
import Image from "next/image";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import io from 'socket.io-client';
export default function Home() {
const nameInputRef = useRef<HTMLInputElement>(null);
const currentPlayers = useState<string | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
const currentPlayers = useState<string | null>(null);
const [apiConnected, setAPIStatus] = useState<boolean>(false);
const [gameStatus, setGameStatus] = useState<{ gameActive: boolean, players: any[] }>({
gameActive: false,
players: [],
});
const addPlayer = async () => {
const playerName = nameInputRef.current?.value?.trim();
if (!playerName) {
alert("Please enter a player name");
return;
}
let baseUrl = "";
if (typeof window !== "undefined") {
baseUrl = `${window.location.protocol}//${window.location.hostname}`;
}
const backendUrl = `${baseUrl}:${process.env.NEXT_PUBLIC_API_BASE}`;
const socket = io(`${backendUrl}`);
const fetchStatus = async () => {
try {
console.log(`${process.env.NEXT_PUBLIC_API_BASE}`);
const res = await fetch(`${process.env.NEXT_PUBLIC_API_BASE}/add`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: playerName, group: selectedGroup }),
});
if (!res.ok) {
throw new Error(`Server error: ${res.statusText}`);
}
if (nameInputRef.current) {
nameInputRef.current.value = "";
}
} catch (error) {
console.error("Failed to add player:", error);
const res = await fetch(`${backendUrl}/status`);
const data = await res.json();
setAPIStatus(true);
setGameStatus(data);
} catch (err) {
console.error("Failed to fetch status", err);
setAPIStatus(false);
}
};
const [selectedGroup, setSelectedGroup] = useState<"stripes" | "solids">("stripes");
const advanceTurn = async () => {
await fetch('http://localhost:8080/next'); // triggers backend to emit event
};
// useEffect(() => {
// fetchStatus();
// // Need to change this so it just listens on a socket instead
// const interval = setInterval(fetchStatus, 5000);
// return () => clearInterval(interval);
// }, []);
const resetGame = async () => {
await fetch('http://localhost:8080/reset'); // triggers backend to emit event
};
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
Game Info
useEffect(() => {
socket.on('connect', () => {
console.log('Connected to Socket.IO server');
setAPIStatus(true);
fetchStatus();
});
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<div className="flex gap-8 mb-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="group"
value="stripes"
checked={selectedGroup === "stripes"}
onChange={() => setSelectedGroup("stripes")}
className="cursor-pointer"
/>
Stripes
</label>
socket.on('player_update', (data: { nextPlayer: string }) => {
fetchStatus();
});
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="group"
value="solids"
checked={selectedGroup === "solids"}
onChange={() => setSelectedGroup("solids")}
className="cursor-pointer"
/>
Solids
</label>
return () => {
socket.off('player_update');
};
}, []);
const addPlayer = async () => {
const playerName = nameInputRef.current?.value?.trim();
if (!playerName) {
alert("Please enter a player name");
return;
}
try {
console.log(`${process.env.NEXT_PUBLIC_API_BASE}`);
const res = await fetch(`${backendUrl}/add`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: playerName, group: selectedGroup }),
});
if (!res.ok) {
throw new Error(`Server error: ${res.statusText}`);
}
if (nameInputRef.current) {
nameInputRef.current.value = "";
}
} catch (error) {
console.error("Failed to add player:", error);
}
};
const startGame = async() => {
fetch(`${backendUrl}/start`);
}
const [selectedGroup, setSelectedGroup] = useState<"stripes" | "solids">("stripes");
const advanceTurn = async () => {
await fetch(`${backendUrl}/next`); // triggers backend to emit event
};
const resetGame = async () => {
await fetch(`${backendUrl}/reset`); // triggers backend to emit event
};
console.log(apiConnected);
if (apiConnected) {
return (
<div>
<div className="border rounded p-4 max-w-md m-5">
<h2 className="text-xl font-semibold mb-3">🎯 Game Info</h2>
<p className="mb-2">Game Active: <strong>{gameStatus.gameActive ? "Yes" : "No"}</strong></p>
<table className="w-full text-left border-t">
<thead>
<tr><th className="py-1">Player</th><th>Group</th></tr>
</thead>
<tbody>
{gameStatus.players.map((player, idx) => (
<tr key={idx} className="border-t">
<td className="py-1">{player.name}</td>
<td>{player.group}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<div className="flex gap-8 mb-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="group"
value="stripes"
checked={selectedGroup === "stripes"}
onChange={() => setSelectedGroup("stripes")}
className="cursor-pointer"
/>
Stripes
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="group"
value="solids"
checked={selectedGroup === "solids"}
onChange={() => setSelectedGroup("solids")}
className="cursor-pointer"
/>
Solids
</label>
</div>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<input ref={nameInputRef} id="name" placeholder="Enter Name Here"></input>
</div>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<button
onClick={addPlayer}
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto">
<Image
className="dark:invert"
src="/add.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Add Player</button>
<button
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
onClick={startGame}
>
<Image
className="dark:invert"
src="/play-button.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Start Game
</button>
<button
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
onClick={resetGame}
>
<Image
className="dark:invert"
src="/reset.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Reset Game</button>
<button
onClick={advanceTurn}
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto">
<Image
className="dark:invert"
src="/next.svg"
alt="Vercel logomark"
width={20}
height={20}
/>Next Turn</button>
</div>
</main>
</div>
</div>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<input ref={nameInputRef} id="name" placeholder="Enter Name Here"></input>
<button
onClick={addPlayer}
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto">Add Player</button>
);
}
else
return (
<div>
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<p>Not Connected to API {apiConnected}</p>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<button
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
onClick={startGame}
>
<Image
className="dark:invert"
src="/play-button.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Try Reconnecting
</button>
</div>
</main>
</div>
</div>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Start Game
</a>
<button
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
onClick={resetGame}
>
Reset Game</button>
<button
onClick={advanceTurn}
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto">Next Turn</button>
</div>
</main>
</div>
);
);
}
+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

After

Width:  |  Height:  |  Size: 565 B

+7 -1
View File
@@ -1 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 901 B

+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" xmlns:xlink="http://www.w3.org/1999/xlink" enable-background="new 0 0 512 512" fill="#f6f5f4">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

After

Width:  |  Height:  |  Size: 967 B

+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" fill="#000000">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

After

Width:  |  Height:  |  Size: 1.6 KiB

+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" stroke-width="3" stroke="#FFFFFF" fill="none"><line x1="8.06" y1="8.06" x2="55.41" y2="55.94"/><line x1="55.94" y1="8.06" x2="8.59" y2="55.94"/></svg>

After

Width:  |  Height:  |  Size: 358 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB