From 6696d4c23561d0c87996430e073fa30fafa6db31 Mon Sep 17 00:00:00 2001 From: Joshua Higgins Date: Wed, 15 Apr 2026 12:21:36 -0400 Subject: [PATCH] feat: admin controls --- connect4-ui/app/spectate/page.tsx | 12 +- connect4-ui/components/AdminSettingsPanel.tsx | 726 ++++++++++++++++++ connect4-ui/components/Celebration.tsx | 2 - connect4-ui/components/Nav.tsx | 78 +- connect4-ui/lib/connection.tsx | 48 +- connect4-ui/lib/protocol.ts | 47 +- connect4-ui/package-lock.json | 10 + connect4-ui/package.json | 1 + 8 files changed, 888 insertions(+), 36 deletions(-) create mode 100644 connect4-ui/components/AdminSettingsPanel.tsx diff --git a/connect4-ui/app/spectate/page.tsx b/connect4-ui/app/spectate/page.tsx index 4d9fd89..6199cc2 100644 --- a/connect4-ui/app/spectate/page.tsx +++ b/connect4-ui/app/spectate/page.tsx @@ -60,7 +60,6 @@ export default function SpectatePage() { status, send, subscribe, - disconnect, shouldRedirectToConnect, clearRedirectFlag, } = useConnection(); @@ -72,7 +71,6 @@ export default function SpectatePage() { const [gameList, setGameList] = useState([]); const [liveGames, setLiveGames] = useState>(new Map()); const [selectedGame, setSelectedGame] = useState(null); - const [log, setLog] = useState([]); const [knockoutRawData, setKnockoutRawData] = useState(""); const [tournamentWinner, setTournamentWinner] = useState(null); @@ -80,11 +78,11 @@ export default function SpectatePage() { const initialBoardSyncPendingRef = useRef(true); const addLog = useCallback( - (msg: string) => - setLog((prev) => [ - `[${new Date().toLocaleTimeString()}] ${msg}`, - ...prev.slice(0, 79), - ]), + (msg: string) => { + if (process.env.NODE_ENV === "development") { + console.log(`[spectate] ${msg}`); + } + }, [], ); diff --git a/connect4-ui/components/AdminSettingsPanel.tsx b/connect4-ui/components/AdminSettingsPanel.tsx new file mode 100644 index 0000000..523b895 --- /dev/null +++ b/connect4-ui/components/AdminSettingsPanel.tsx @@ -0,0 +1,726 @@ +"use client"; + +import { FormEvent, useEffect, useRef, useState } from "react"; +import { Plus, RefreshCw, Save, Trash2 } from "lucide-react"; +import { cmd, ParsedMessage, ReservationEntry } from "@/lib/protocol"; +import { useConnection } from "@/lib/connection"; + +const GETTABLE_DATA_KEYS = [ + "TOURNAMENT_STATUS", + "TOURNAMENT_DATA", + "MOVE_WAIT", + "DEMO_MODE", + "MAX_TIMEOUT", + "BRACKET_PAIRINGS", +] as const; + +const EDITABLE_DATA_KEYS = ["DEMO_MODE", "MAX_TIMEOUT", "MOVE_WAIT"] as const; +const TOURNAMENT_TYPES = ["RoundRobin", "KnockoutBracket"] as const; +const TOURNAMENT_TYPE_LABELS: Record< + (typeof TOURNAMENT_TYPES)[number], + string +> = { + RoundRobin: "Round Robin", + KnockoutBracket: "Knockout Bracket", +}; +const VARIABLE_LABELS: Record<(typeof EDITABLE_DATA_KEYS)[number], string> = { + DEMO_MODE: "Demo Mode", + MAX_TIMEOUT: "Max Timeout", + MOVE_WAIT: "Move Wait", +}; + +function parseBracketPairings(value: string): string[] { + return value + .split(",") + .map((username) => username.trim()) + .filter((username) => username.length > 0); +} + +function serializeBracketPairings(players: string[]): string { + return players.join(","); +} + +function formatTournamentType(type: string): string { + if (type === "RoundRobin" || type === "KnockoutBracket") { + return TOURNAMENT_TYPE_LABELS[type]; + } + return type; +} + +export default function AdminSettingsPanel() { + const { authenticateAdmin, isAdmin, send, status, subscribe } = + useConnection(); + const [adminPassword, setAdminPassword] = useState(""); + const [adminFeedback, setAdminFeedback] = useState(null); + const [selectedTournamentType, setSelectedTournamentType] = + useState<(typeof TOURNAMENT_TYPES)[number]>("RoundRobin"); + const [serverValues, setServerValues] = useState>({}); + const [editableValues, setEditableValues] = useState>( + {}, + ); + const [bracketPlayer, setBracketPlayer] = useState(""); + const [bracketPairings, setBracketPairings] = useState([]); + const [reservationPlayer1, setReservationPlayer1] = useState(""); + const [reservationPlayer2, setReservationPlayer2] = useState(""); + const [reservations, setReservations] = useState([]); + const [actionFeedback, setActionFeedback] = useState(null); + const pendingAdminAuthRef = useRef(false); + const pendingSetRef = useRef>({}); + + const isConnected = status === "connected"; + const adminControlsDisabled = !isConnected || !isAdmin; + const hasActiveTournament = Boolean( + serverValues.TOURNAMENT_STATUS && + serverValues.TOURNAMENT_STATUS !== "false", + ); + const hasVariableChanges = EDITABLE_DATA_KEYS.some( + (key) => + editableValues[key] !== undefined && + editableValues[key] !== serverValues[key], + ); + const serializedBracketPairings = serializeBracketPairings(bracketPairings); + const hasBracketPairingChanges = + serializedBracketPairings !== (serverValues.BRACKET_PAIRINGS ?? ""); + + useEffect(() => { + const unsubscribe = subscribe((message: ParsedMessage) => { + switch (message.type) { + case "ADMIN_AUTH_ACK": + pendingAdminAuthRef.current = false; + setAdminFeedback("Admin access granted."); + break; + case "GET_DATA": + setServerValues((prev) => ({ + ...prev, + [message.key]: message.value, + })); + if ( + EDITABLE_DATA_KEYS.includes( + message.key as (typeof EDITABLE_DATA_KEYS)[number], + ) + ) { + setEditableValues((prev) => ({ + ...prev, + [message.key]: message.value, + })); + } + if (message.key === "BRACKET_PAIRINGS") { + setBracketPairings(parseBracketPairings(message.value)); + } + setActionFeedback(`Loaded ${message.key}.`); + break; + case "SET_DATA_ACK": + setServerValues((prev) => ({ + ...prev, + [message.key]: + pendingSetRef.current[message.key] ?? prev[message.key] ?? "", + })); + setEditableValues((prev) => ({ + ...prev, + [message.key]: + pendingSetRef.current[message.key] ?? prev[message.key] ?? "", + })); + if (message.key === "BRACKET_PAIRINGS") { + setBracketPairings( + parseBracketPairings(pendingSetRef.current[message.key] ?? ""), + ); + } + delete pendingSetRef.current[message.key]; + setActionFeedback(`Updated ${message.key}.`); + break; + case "TOURNAMENT_START": + setServerValues((prev) => ({ + ...prev, + TOURNAMENT_STATUS: message.tournamentType, + })); + setActionFeedback( + `Started ${formatTournamentType(message.tournamentType)}.`, + ); + break; + case "TOURNAMENT_CANCEL": + setServerValues((prev) => ({ + ...prev, + TOURNAMENT_STATUS: "false", + })); + setActionFeedback("Tournament cancelled."); + break; + case "TOURNAMENT_END": + setServerValues((prev) => ({ + ...prev, + TOURNAMENT_STATUS: "false", + })); + break; + case "RESERVATION_LIST": + setReservations(message.reservations); + break; + case "RESERVATION_ADD": + setReservations((prev) => { + const exists = prev.some( + (reservation) => + reservation.player1 === message.player1 && + reservation.player2 === message.player2, + ); + return exists + ? prev + : [ + ...prev, + { player1: message.player1, player2: message.player2 }, + ]; + }); + setActionFeedback(null); + break; + case "RESERVATION_DELETE": + setReservations((prev) => + prev.filter( + (reservation) => + !( + reservation.player1 === message.player1 && + reservation.player2 === message.player2 + ), + ), + ); + setActionFeedback(null); + break; + case "ERROR": + if (pendingAdminAuthRef.current) { + pendingAdminAuthRef.current = false; + setAdminFeedback("Admin authentication failed."); + } + setActionFeedback(message.message); + break; + default: + break; + } + }); + + return unsubscribe; + }, [subscribe]); + + useEffect(() => { + if (!isAdmin || !isConnected) { + return; + } + + GETTABLE_DATA_KEYS.forEach((key) => { + send(cmd.getData(key)); + }); + send(cmd.reservationGet()); + }, [isAdmin, isConnected, send]); + + const handleAdminAuth = (event: FormEvent) => { + event.preventDefault(); + const sent = authenticateAdmin(adminPassword); + pendingAdminAuthRef.current = sent; + setAdminFeedback( + sent + ? "Authenticating admin session..." + : "Connect before authenticating.", + ); + if (sent) { + setAdminPassword(""); + } + }; + + const handleTournamentStart = () => { + if (hasActiveTournament) { + setActionFeedback("A tournament is already active."); + return; + } + if (!send(cmd.tournamentStart(selectedTournamentType))) { + setActionFeedback("Unable to start tournament while disconnected."); + return; + } + setActionFeedback( + `Starting ${formatTournamentType(selectedTournamentType)} tournament...`, + ); + }; + + const handleTournamentCancel = () => { + if (!hasActiveTournament) { + setActionFeedback("No active tournament to cancel."); + return; + } + if (!send(cmd.tournamentCancel())) { + setActionFeedback("Unable to cancel tournament while disconnected."); + return; + } + setActionFeedback("Cancelling tournament..."); + }; + + const handleEditableValueChange = (key: string, value: string) => { + setEditableValues((prev) => ({ + ...prev, + [key]: value, + })); + }; + + const handleSaveVariables = () => { + const changes = EDITABLE_DATA_KEYS.filter( + (key) => + editableValues[key] !== undefined && + editableValues[key] !== serverValues[key], + ); + + if (changes.length === 0) { + setActionFeedback("No variable changes to save."); + return; + } + + for (const key of changes) { + const value = editableValues[key]; + if (!send(cmd.setData(key, value))) { + setActionFeedback("Unable to send SET command while disconnected."); + return; + } + pendingSetRef.current[key] = value; + } + + setActionFeedback( + `Saving ${changes.length} variable${changes.length === 1 ? "" : "s"}...`, + ); + }; + + const handleReservationAction = (action: "add" | "delete") => { + const player1 = reservationPlayer1.trim(); + const player2 = reservationPlayer2.trim(); + if (!player1 || !player2) return; + + const message = + action === "add" + ? cmd.reservationAdd(player1, player2) + : cmd.reservationDelete(player1, player2); + const sent = send(message); + if (!sent) { + setActionFeedback( + "Unable to send reservation command while disconnected.", + ); + return; + } + + setActionFeedback( + `${action === "add" ? "Adding" : "Removing"} reservation for ${player1} vs ${player2}...`, + ); + setReservationPlayer1(""); + setReservationPlayer2(""); + }; + + const handleReservationDeletePair = (player1: string, player2: string) => { + const sent = send(cmd.reservationDelete(player1, player2)); + if (!sent) { + setActionFeedback( + "Unable to send reservation command while disconnected.", + ); + return; + } + + setActionFeedback(`Removing reservation for ${player1} vs ${player2}...`); + }; + + const handleRefreshReservations = () => { + if (!send(cmd.reservationGet())) { + setActionFeedback("Unable to fetch reservations while disconnected."); + return; + } + setActionFeedback("Fetching reservations..."); + }; + + const handleBracketPairingAdd = () => { + const player = bracketPlayer.trim(); + if (!player) return; + + setBracketPairings((prev) => [...prev, player]); + setBracketPlayer(""); + }; + + const handleBracketPairingDelete = (index: number) => { + setBracketPairings((prev) => + prev.filter((_, pairingIndex) => pairingIndex !== index), + ); + }; + + const handleRefreshBracketPairings = () => { + if (!send(cmd.getData("BRACKET_PAIRINGS"))) { + setActionFeedback("Unable to fetch bracket pairings while disconnected."); + return; + } + setActionFeedback("Fetching bracket pairings..."); + }; + + const handleSaveBracketPairings = () => { + if (!hasBracketPairingChanges) { + setActionFeedback("No bracket pairing changes to save."); + return; + } + + if (!send(cmd.setData("BRACKET_PAIRINGS", serializedBracketPairings))) { + setActionFeedback("Unable to send SET command while disconnected."); + return; + } + + pendingSetRef.current.BRACKET_PAIRINGS = serializedBracketPairings; + setActionFeedback("Saving bracket pairings..."); + }; + + return ( +
+ {!isAdmin && ( +
+

+ Authenticate +

+

+ Become an admin to manage tournaments, server variables, and + reservations. +

+
+
+ setAdminPassword(event.target.value)} + placeholder="Password" + disabled={!isConnected} + className="w-[50%] rounded-lg border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60" + /> + +
+
+ + {adminFeedback && ( +
+ {adminFeedback} +
+ )} +
+ )} + + {isAdmin && ( +
+
+

Tournaments

+

+ Start or cancel tournaments from the live spectator view. +

+
+ +
+ + +
+ +
+
+
+

+ Bracket Pairings +

+

+ Use custom seeding for knockout bracket. +

+
+
+ + +
+
+ +
{ + event.preventDefault(); + handleBracketPairingAdd(); + }} + > + setBracketPlayer(event.target.value)} + disabled={adminControlsDisabled} + placeholder="Player" + className="rounded-lg border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60" + /> + +
+ +
+ {bracketPairings.length === 0 ? ( +

+ No bracket players added. +

+ ) : ( +
+ {bracketPairings.map((player, index) => ( +
+ {player} + +
+ ))} +
+ )} +
+
+
+
+ +
+
+
+

+ Server Variables +

+

+ Modify server settings and variables. +

+
+ +
+
+
+
+
+ {VARIABLE_LABELS.DEMO_MODE} +
+ + handleEditableValueChange( + "DEMO_MODE", + event.target.checked ? "true" : "false", + ) + } + disabled={adminControlsDisabled} + className="h-4 w-4 rounded border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500 disabled:cursor-not-allowed" + /> +
+
+ + {(["MAX_TIMEOUT", "MOVE_WAIT"] as const).map((key) => ( +
+
+
+ {VARIABLE_LABELS[key]} +
+ + handleEditableValueChange(key, event.target.value) + } + disabled={adminControlsDisabled} + className="w-32 rounded-lg border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 sm:w-40" + /> + s +
+
+ ))} + +
+ +
+
+
+
+ )} + + {isAdmin && ( +
+
+
+

Reservations

+

+ Create, remove, and review match reservations for specific + players. +

+
+ +
+ +
{ + event.preventDefault(); + handleReservationAction("add"); + }} + > + setReservationPlayer1(event.target.value)} + disabled={adminControlsDisabled} + placeholder="Player 1" + className="rounded-lg border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60" + /> + setReservationPlayer2(event.target.value)} + disabled={adminControlsDisabled} + placeholder="Player 2" + className="rounded-lg border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60" + /> + +
+ +
+ {reservations.length === 0 ? ( +

No reservations exist.

+ ) : ( + reservations.map((reservation) => ( +
+ + {reservation.player1} vs {reservation.player2} + + +
+ )) + )} +
+
+ )} + + {actionFeedback && !actionFeedback.startsWith("Loaded ") && ( +
+ {actionFeedback} +
+ )} +
+ ); +} diff --git a/connect4-ui/components/Celebration.tsx b/connect4-ui/components/Celebration.tsx index a8784cb..4e01938 100644 --- a/connect4-ui/components/Celebration.tsx +++ b/connect4-ui/components/Celebration.tsx @@ -23,8 +23,6 @@ export default function Celebration() { }, []); useEffect(() => { - let timeoutId: ReturnType | null = null; - const unsubscribe = subscribe((msg) => { if (msg.type !== "TOURNAMENT_WINNER") return; diff --git a/connect4-ui/components/Nav.tsx b/connect4-ui/components/Nav.tsx index 9256910..eb104cf 100644 --- a/connect4-ui/components/Nav.tsx +++ b/connect4-ui/components/Nav.tsx @@ -1,27 +1,37 @@ "use client"; import Link from "next/link"; -import { SubmitEvent, useState } from "react"; +import { SubmitEvent, useEffect, useState } from "react"; +import { Settings, X } from "lucide-react"; import { useRouter } from "next/navigation"; import { usePathname } from "next/navigation"; +import AdminSettingsPanel from "@/components/AdminSettingsPanel"; import { useConnection } from "@/lib/connection"; -import { cmd } from "@/lib/protocol"; export default function Nav() { const pathname = usePathname(); const router = useRouter(); - const { status, role, username, send, becomePlayer, disconnect } = - useConnection(); + const { + status, + role, + username, + becomePlayer, + disconnect, + isAdmin, + shouldRedirectToConnect, + } = useConnection(); const [showPlayerModal, setShowPlayerModal] = useState(false); + const [showSettingsModal, setShowSettingsModal] = useState(false); const [nextUsername, setNextUsername] = useState(username); const isConnectionPage = pathname === "/"; const disableRoleSwitch = status === "connecting" || status === "reconnecting"; - const handleBecomeObserver = () => { - send(cmd.disconnect()); - router.push("/spectate"); - }; + useEffect(() => { + if (isConnectionPage || (status === "disconnected" && shouldRedirectToConnect)) { + setShowSettingsModal(false); + } + }, [isConnectionPage, status, shouldRedirectToConnect]); const handleBecomePlayer = (event: SubmitEvent) => { event.preventDefault(); @@ -48,7 +58,7 @@ export default function Nav() {
{!isConnectionPage && ( <> - {role !== "player" && ( + {role !== "player" && !isAdmin && ( + {role !== "player" && ( + + )} )}
@@ -109,6 +130,45 @@ export default function Nav() { )} + + {showSettingsModal && ( +
+
+
+
+
+

Settings

+

+ Admin tools for tournaments, server values, and reservations. +

+
+
+ + {isAdmin ? "Admin" : "Observer"} + + +
+
+
+ +
+
+
+
+ )} ); } diff --git a/connect4-ui/lib/connection.tsx b/connect4-ui/lib/connection.tsx index 55f3b69..f29b726 100644 --- a/connect4-ui/lib/connection.tsx +++ b/connect4-ui/lib/connection.tsx @@ -40,9 +40,11 @@ interface ConnectionContextValue { username: string; status: ConnectionStatus; isInMatch: boolean; + isAdmin: boolean; reconnectAttempts: number; shouldRedirectToConnect: boolean; becomePlayer: (username: string) => void; + authenticateAdmin: (password: string) => boolean; connect: (options: ConnectOptions) => void; disconnect: () => void; send: (message: string) => boolean; @@ -68,6 +70,7 @@ export function ConnectionProvider({ const [username, setUsername] = useState(""); const [status, setStatus] = useState("idle"); const [isInMatch, setIsInMatch] = useState(false); + const [isAdmin, setIsAdmin] = useState(false); const [reconnectAttempts, setReconnectAttempts] = useState(0); const [shouldRedirectToConnect, setShouldRedirectToConnect] = useState(false); @@ -169,25 +172,33 @@ export function ConnectionProvider({ setRole("observer"); setShouldRedirectToConnect(false); setStatus("connected"); + setIsAdmin(false); } if (parsed.type === "CONNECT_ACK") { setRole("player"); + setIsAdmin(false); } if (parsed.type === "RECONNECT_ACK") { clearReconnectState(); setShouldRedirectToConnect(false); setStatus("connected"); + setIsAdmin(false); } if (parsed.type === "DISCONNECT_ACK") { setRole("observer"); setUsername(""); + setIsAdmin(false); isInMatchRef.current = false; setIsInMatch(false); } + if (parsed.type === "ADMIN_AUTH_ACK") { + setIsAdmin(true); + } + if (parsed.type === "GAME_START") { isInMatchRef.current = true; setIsInMatch(true); @@ -286,6 +297,7 @@ export function ConnectionProvider({ clearReconnectState(); isInMatchRef.current = false; setIsInMatch(false); + setIsAdmin(false); setStatus("connecting"); openSocket(false); @@ -293,6 +305,15 @@ export function ConnectionProvider({ [clearReconnectState, openSocket], ); + const send = useCallback((message: string) => { + if (wsRef.current?.readyState !== WebSocket.OPEN) return false; + if (process.env.NODE_ENV === "development") { + console.log("Sending: " + message); + } + wsRef.current.send(message); + return true; + }, []); + const becomePlayer = useCallback( (username: string) => { const resolvedUsername = (username ?? "").trim(); @@ -300,9 +321,19 @@ export function ConnectionProvider({ setUsername(resolvedUsername); isInMatchRef.current = false; setIsInMatch(false); + setIsAdmin(false); send(cmd.connect(resolvedUsername)); }, - [clearReconnectState, openSocket], + [send], + ); + + const authenticateAdmin = useCallback( + (password: string) => { + const trimmed = password.trim(); + if (!trimmed) return false; + return send(cmd.adminAuth(trimmed)); + }, + [send], ); const disconnect = useCallback(() => { @@ -315,19 +346,11 @@ export function ConnectionProvider({ setStatus("idle"); setUsername(""); setIsInMatch(false); + setIsAdmin(false); isInMatchRef.current = false; setShouldRedirectToConnect(false); }, [clearReconnectState, safeCloseSocket]); - const send = useCallback((message: string) => { - if (wsRef.current?.readyState !== WebSocket.OPEN) return false; - if (process.env.NODE_ENV === "development") { - console.log("Sending: " + message); - } - wsRef.current.send(message); - return true; - }, []); - const subscribe = useCallback((listener: MessageListener) => { listenersRef.current.add(listener); return () => { @@ -354,9 +377,11 @@ export function ConnectionProvider({ username, status, isInMatch, + isAdmin, reconnectAttempts, shouldRedirectToConnect, becomePlayer, + authenticateAdmin, connect, disconnect, send, @@ -369,8 +394,11 @@ export function ConnectionProvider({ username, status, isInMatch, + isAdmin, reconnectAttempts, shouldRedirectToConnect, + becomePlayer, + authenticateAdmin, connect, disconnect, send, diff --git a/connect4-ui/lib/protocol.ts b/connect4-ui/lib/protocol.ts index 51d02be..3f5babe 100644 --- a/connect4-ui/lib/protocol.ts +++ b/connect4-ui/lib/protocol.ts @@ -22,6 +22,11 @@ export interface MoveEntry { column: number; } +export interface ReservationEntry { + player1: string; + player2: string; +} + export const DEFAULT_WS_URL = process.env.NODE_ENV === "development" ? "ws://localhost:8080" @@ -69,6 +74,9 @@ export type ParsedMessage = | { type: "TOURNAMENT_WINNER"; username: string } | { type: "TOURNAMENT_END" } | { type: "ADMIN_AUTH_ACK" } + | { type: "RESERVATION_ADD"; player1: string; player2: string } + | { type: "RESERVATION_DELETE"; player1: string; player2: string } + | { type: "RESERVATION_LIST"; reservations: ReservationEntry[] } | { type: "GET_DATA"; key: string; value: string } | { type: "SET_DATA_ACK"; key: string } | { type: "ERROR"; message: string } @@ -257,6 +265,36 @@ export function parseMessage(raw: string): ParsedMessage { if (parts[2] === "ACK") return { type: "SET_DATA_ACK", key: parts[1] }; break; + case "RESERVATION": { + const payload = parts[2] ?? ""; + + if (parts[1] === "ADD" || parts[1] === "DELETE") { + const [player1, player2] = payload.split(","); + if (player1 && player2) { + return { + type: parts[1] === "ADD" ? "RESERVATION_ADD" : "RESERVATION_DELETE", + player1, + player2, + }; + } + } + + if (parts[1] === "LIST") { + const reservations = + payload.length === 0 + ? [] + : payload + .split("|") + .map((entry) => { + const [player1, player2] = entry.split(","); + return player1 && player2 ? { player1, player2 } : null; + }) + .filter((entry): entry is ReservationEntry => entry !== null); + return { type: "RESERVATION_LIST", reservations }; + } + break; + } + case "ERROR": return { type: "ERROR", message: raw }; } @@ -283,14 +321,7 @@ export const cmd = { adminKick: (username: string) => `ADMIN:KICK:${username}`, tournamentStart: (type = "RoundRobin") => `TOURNAMENT:START:${type}`, tournamentCancel: () => "TOURNAMENT:CANCEL", - getData: ( - key: - | "TOURNAMENT_STATUS" - | "TOURNAMENT_DATA" - | "MOVE_WAIT" - | "DEMO_MODE" - | "MAX_TIMEOUT", - ) => `GET:${key}`, + getData: (key: string) => `GET:${key}`, setData: (key: string, value: string) => `SET:${key}:${value}`, reservationAdd: (p1: string, p2: string) => `RESERVATION:ADD:${p1},${p2}`, reservationDelete: (p1: string, p2: string) => diff --git a/connect4-ui/package-lock.json b/connect4-ui/package-lock.json index 0b98576..e18d2b0 100644 --- a/connect4-ui/package-lock.json +++ b/connect4-ui/package-lock.json @@ -8,6 +8,7 @@ "name": "connect4-ui", "version": "0.1.0", "dependencies": { + "lucide-react": "^1.8.0", "next": "15.2.4", "react": "^19.0.0", "react-confetti": "^6.4.0", @@ -4438,6 +4439,15 @@ "loose-envify": "cli.js" } }, + "node_modules/lucide-react": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz", + "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/connect4-ui/package.json b/connect4-ui/package.json index 0a2a447..0a07ba1 100644 --- a/connect4-ui/package.json +++ b/connect4-ui/package.json @@ -11,6 +11,7 @@ "format:check": "prettier --check ." }, "dependencies": { + "lucide-react": "^1.8.0", "next": "15.2.4", "react": "^19.0.0", "react-confetti": "^6.4.0",