feat: admin controls

This commit is contained in:
2026-04-15 12:21:36 -04:00
Unverified
parent bc6cb9f162
commit 6696d4c235
8 changed files with 888 additions and 36 deletions

View File

@@ -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<GameEntry[]>([]);
const [liveGames, setLiveGames] = useState<Map<number, LiveGame>>(new Map());
const [selectedGame, setSelectedGame] = useState<number | null>(null);
const [log, setLog] = useState<string[]>([]);
const [knockoutRawData, setKnockoutRawData] = useState("");
const [tournamentWinner, setTournamentWinner] = useState<string | null>(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}`);
}
},
[],
);

View File

@@ -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<string | null>(null);
const [selectedTournamentType, setSelectedTournamentType] =
useState<(typeof TOURNAMENT_TYPES)[number]>("RoundRobin");
const [serverValues, setServerValues] = useState<Record<string, string>>({});
const [editableValues, setEditableValues] = useState<Record<string, string>>(
{},
);
const [bracketPlayer, setBracketPlayer] = useState("");
const [bracketPairings, setBracketPairings] = useState<string[]>([]);
const [reservationPlayer1, setReservationPlayer1] = useState("");
const [reservationPlayer2, setReservationPlayer2] = useState("");
const [reservations, setReservations] = useState<ReservationEntry[]>([]);
const [actionFeedback, setActionFeedback] = useState<string | null>(null);
const pendingAdminAuthRef = useRef(false);
const pendingSetRef = useRef<Record<string, string>>({});
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<HTMLFormElement>) => {
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 (
<section className="rounded-xl flex flex-col gap-4">
{!isAdmin && (
<div className="rounded-xl border border-gray-700 bg-[#0B111F] p-4">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider">
Authenticate
</h2>
<p className="mt-1 text-sm text-gray-400">
Become an admin to manage tournaments, server variables, and
reservations.
</p>
<form className="mt-4 flex flex-col gap-3" onSubmit={handleAdminAuth}>
<div className="flex flex-col gap-2 sm:flex-row">
<input
type="password"
value={adminPassword}
onChange={(event) => 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"
/>
<button
type="submit"
disabled={!isConnected}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-gray-700 disabled:text-gray-500"
>
Login
</button>
</div>
</form>
{adminFeedback && (
<div className="mt-3 rounded-lg border border-blue-800 bg-blue-950/30 px-3 py-2 text-sm text-blue-200">
{adminFeedback}
</div>
)}
</div>
)}
{isAdmin && (
<div className="grid gap-4 xl:grid-cols-2">
<div className="rounded-xl border border-gray-700 bg-[#0B111F] p-4">
<h3 className="text-sm font-semibold text-white">Tournaments</h3>
<p className="mt-1 text-sm text-gray-400">
Start or cancel tournaments from the live spectator view.
</p>
<div className="mt-4 flex flex-col gap-3">
<select
onChange={(event) =>
setSelectedTournamentType(
event.target.value as (typeof TOURNAMENT_TYPES)[number],
)
}
disabled={adminControlsDisabled || hasActiveTournament}
value={
hasActiveTournament &&
(serverValues.TOURNAMENT_STATUS === "RoundRobin" ||
serverValues.TOURNAMENT_STATUS === "KnockoutBracket")
? (serverValues.TOURNAMENT_STATUS as (typeof TOURNAMENT_TYPES)[number])
: selectedTournamentType
}
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"
>
{TOURNAMENT_TYPES.map((type) => (
<option key={type} value={type}>
{TOURNAMENT_TYPE_LABELS[type]}
</option>
))}
</select>
<div className="flex gap-2">
<button
type="button"
onClick={handleTournamentStart}
disabled={adminControlsDisabled || hasActiveTournament}
className="flex-1 rounded-lg bg-green-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-green-500 disabled:cursor-not-allowed disabled:bg-gray-700 disabled:text-gray-500"
>
Start Tournament
</button>
<button
type="button"
onClick={handleTournamentCancel}
disabled={adminControlsDisabled || !hasActiveTournament}
className="flex-1 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-500 disabled:cursor-not-allowed disabled:bg-gray-700 disabled:text-gray-500"
>
Cancel
</button>
</div>
<div className="mt-6">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-white">
Bracket Pairings
</h4>
<p className="mt-1 text-sm text-gray-400">
Use custom seeding for knockout bracket.
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={handleRefreshBracketPairings}
disabled={adminControlsDisabled}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition-colors hover:bg-gray-700 hover:text-blue-300 disabled:cursor-not-allowed disabled:bg-gray-800 disabled:text-gray-600"
aria-label="Refresh bracket pairings"
title="Refresh bracket pairings"
>
<RefreshCw className="h-4 w-4" />
</button>
<button
type="button"
onClick={handleSaveBracketPairings}
disabled={
adminControlsDisabled || !hasBracketPairingChanges
}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition-colors hover:bg-gray-700 hover:text-blue-300 disabled:cursor-not-allowed disabled:bg-gray-800 disabled:text-gray-600"
aria-label="Save bracket pairings"
title="Save bracket pairings"
>
<Save className="h-4 w-4" />
</button>
</div>
</div>
<form
className="mt-4 grid gap-3 md:grid-cols-[1fr_auto]"
onSubmit={(event) => {
event.preventDefault();
handleBracketPairingAdd();
}}
>
<input
value={bracketPlayer}
onChange={(event) => 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"
/>
<button
type="submit"
disabled={adminControlsDisabled}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition-colors hover:bg-gray-700 hover:text-blue-300 disabled:cursor-not-allowed disabled:bg-gray-800 disabled:text-gray-600"
aria-label="Add bracket player"
title="Add bracket player"
>
<Plus className="h-4 w-4" />
</button>
</form>
<div className="mt-4 flex flex-col">
{bracketPairings.length === 0 ? (
<p className="text-sm text-gray-500">
No bracket players added.
</p>
) : (
<div className="border border-gray-800 bg-[#050A16] rounded-lg">
{bracketPairings.map((player, index) => (
<div
key={`${player}-${index}`}
className="flex items-center justify-between rounded-lg bg-[#050A16] px-3 py-2 text-sm"
>
<span className="text-white">{player}</span>
<button
type="button"
onClick={() => handleBracketPairingDelete(index)}
disabled={adminControlsDisabled}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-gray-800 hover:text-red-300 disabled:cursor-not-allowed disabled:text-gray-600"
aria-label={`Delete bracket player ${player}`}
title="Delete bracket player"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
<div className="rounded-xl border border-gray-700 bg-[#0B111F] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-white">
Server Variables
</h3>
<p className="mt-1 text-sm text-gray-400">
Modify server settings and variables.
</p>
</div>
<button
type="button"
onClick={() => {
EDITABLE_DATA_KEYS.forEach((key) => {
send(cmd.getData(key));
});
setActionFeedback("Refreshing server variables...");
}}
disabled={adminControlsDisabled}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition-colors hover:bg-gray-700 hover:text-blue-300 disabled:cursor-not-allowed disabled:bg-gray-800 disabled:text-gray-600"
aria-label="Refresh server variables"
title="Refresh server variables"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="mt-4 flex flex-col gap-4">
<div className="rounded-lg border border-gray-800 bg-gray-900/80 px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div className="text-gray-400">
{VARIABLE_LABELS.DEMO_MODE}
</div>
<input
type="checkbox"
checked={editableValues.DEMO_MODE === "true"}
onChange={(event) =>
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"
/>
</div>
</div>
{(["MAX_TIMEOUT", "MOVE_WAIT"] as const).map((key) => (
<div
key={key}
className="rounded-lg border border-gray-800 bg-gray-900/80 px-4 py-3"
>
<div className="flex items-center gap-4">
<div className="min-w-32 shrink-0 text-gray-400">
{VARIABLE_LABELS[key]}
</div>
<input
value={editableValues[key] ?? ""}
onChange={(event) =>
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"
/>
<span className="shrink-0 text-sm text-gray-400">s</span>
</div>
</div>
))}
<div className="flex justify-end">
<button
type="button"
onClick={handleSaveVariables}
disabled={adminControlsDisabled || !hasVariableChanges}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition-colors hover:bg-gray-700 hover:text-blue-300 disabled:cursor-not-allowed disabled:bg-gray-800 disabled:text-gray-600"
aria-label="Save server variables"
title="Save server variables"
>
<Save className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
)}
{isAdmin && (
<div className="rounded-xl border border-gray-700 bg-[#0B111F] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-white">Reservations</h3>
<p className="mt-1 text-sm text-gray-400">
Create, remove, and review match reservations for specific
players.
</p>
</div>
<button
type="button"
onClick={handleRefreshReservations}
disabled={adminControlsDisabled}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition-colors hover:bg-gray-700 hover:text-blue-300 disabled:cursor-not-allowed disabled:bg-gray-800 disabled:text-gray-600"
aria-label="Refresh reservations"
title="Refresh reservations"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
<form
className="mt-4 grid gap-3 md:grid-cols-[1fr_1fr_auto]"
onSubmit={(event) => {
event.preventDefault();
handleReservationAction("add");
}}
>
<input
value={reservationPlayer1}
onChange={(event) => 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"
/>
<input
value={reservationPlayer2}
onChange={(event) => 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"
/>
<button
type="submit"
disabled={adminControlsDisabled}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition-colors hover:bg-gray-700 hover:text-blue-300 disabled:cursor-not-allowed disabled:bg-gray-800 disabled:text-gray-600"
aria-label="Add reservation"
title="Add reservation"
>
<Plus className="h-4 w-4" />
</button>
</form>
<div className="mt-4 flex flex-col gap-2">
{reservations.length === 0 ? (
<p className="text-sm text-gray-500">No reservations exist.</p>
) : (
reservations.map((reservation) => (
<div
key={`${reservation.player1}-${reservation.player2}`}
className="flex items-center justify-between rounded-lg border border-gray-800 bg-gray-900/80 px-3 py-2 text-sm"
>
<span className="text-white">
{reservation.player1} vs {reservation.player2}
</span>
<button
type="button"
onClick={() =>
handleReservationDeletePair(
reservation.player1,
reservation.player2,
)
}
disabled={adminControlsDisabled}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-gray-800 hover:text-red-300 disabled:cursor-not-allowed disabled:text-gray-600"
aria-label={`Delete reservation for ${reservation.player1} versus ${reservation.player2}`}
title="Delete reservation"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))
)}
</div>
</div>
)}
{actionFeedback && !actionFeedback.startsWith("Loaded ") && (
<div className="rounded-lg border border-gray-700 bg-gray-950/80 px-3 py-2 text-sm text-gray-200">
{actionFeedback}
</div>
)}
</section>
);
}

View File

@@ -23,8 +23,6 @@ export default function Celebration() {
}, []);
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const unsubscribe = subscribe((msg) => {
if (msg.type !== "TOURNAMENT_WINNER") return;

View File

@@ -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<HTMLFormElement>) => {
event.preventDefault();
@@ -48,7 +58,7 @@ export default function Nav() {
<div className="ml-auto flex items-center gap-2">
{!isConnectionPage && (
<>
{role !== "player" && (
{role !== "player" && !isAdmin && (
<button
onClick={() => {
setNextUsername(username);
@@ -66,6 +76,17 @@ export default function Nav() {
>
Disconnect
</button>
{role !== "player" && (
<button
type="button"
onClick={() => setShowSettingsModal(true)}
className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-800 text-gray-200 transition-colors hover:bg-gray-700 hover:text-white"
aria-label="Open settings"
title="Settings"
>
<Settings className="h-5 w-5" />
</button>
)}
</>
)}
</div>
@@ -109,6 +130,45 @@ export default function Nav() {
</form>
</div>
)}
{showSettingsModal && (
<div className="fixed inset-0 z-50 bg-black/70 px-4 py-6">
<div className="mx-auto flex h-full max-w-4xl items-center justify-center">
<div className="flex max-h-full w-full flex-col overflow-hidden rounded-2xl border border-gray-700 bg-gray-950 shadow-2xl">
<div className="flex items-center justify-between border-b border-gray-800 px-5 py-4">
<div>
<h2 className="text-lg font-semibold text-white">Settings</h2>
<p className="text-sm text-gray-400">
Admin tools for tournaments, server values, and reservations.
</p>
</div>
<div className="flex items-center gap-3">
<span
className={`rounded-full px-3 py-1 text-xs font-semibold ${
isAdmin
? "border border-green-700 bg-green-950/80 text-green-300"
: "border border-gray-700 bg-gray-800 text-gray-400"
}`}
>
{isAdmin ? "Admin" : "Observer"}
</span>
<button
type="button"
onClick={() => setShowSettingsModal(false)}
className="inline-flex items-center justify-center rounded-lg bg-gray-800 p-2 text-white transition-colors hover:bg-gray-700"
aria-label="Close settings"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<div className="overflow-y-auto p-5">
<AdminSettingsPanel />
</div>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -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<ConnectionStatus>("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,

View File

@@ -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) =>

View File

@@ -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",

View File

@@ -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",