misc: move files to root

This commit is contained in:
2026-04-15 17:29:11 -04:00
Unverified
parent 19aff04e60
commit c72bd9fff6
34 changed files with 127 additions and 15 deletions

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>
);
}

252
components/Board.tsx Normal file
View File

@@ -0,0 +1,252 @@
"use client";
import type { CSSProperties } from "react";
import { useEffect, useRef, useState } from "react";
import type { BoardState } from "@/lib/protocol";
import { CHIP_DROP_SOUND_PATHS } from "@/lib/sfx";
const CHIP_DROP_ANIMATION_MS = 450;
interface BoardProps {
board: BoardState;
onColumnClick?: (column: number) => void;
disabled?: boolean;
lastMove?: { column: number; row: number } | null;
player1?: string;
player2?: string;
currentTurnColor?: 1 | 2 | null;
}
export default function Board({
board,
onColumnClick,
disabled = false,
lastMove,
player1,
player2,
currentTurnColor,
}: BoardProps) {
const [hoveredCol, setHoveredCol] = useState<number | null>(null);
const [animatingMove, setAnimatingMove] = useState<{
column: number;
row: number;
} | null>(null);
const isFirstRender = useRef(true);
const animationTimeoutRef = useRef<number | null>(null);
const chipDropSoundsRef = useRef<HTMLAudioElement[]>([]);
const canInteract = !disabled && !!onColumnClick;
const lastMoveColumn = lastMove?.column ?? null;
const lastMoveRow = lastMove?.row ?? null;
useEffect(() => {
chipDropSoundsRef.current = CHIP_DROP_SOUND_PATHS.map((path) => {
const sound = new Audio(path);
sound.preload = "auto";
return sound;
});
return () => {
chipDropSoundsRef.current.forEach((sound) => {
sound.pause();
sound.src = "";
});
chipDropSoundsRef.current = [];
};
}, []);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
if (animationTimeoutRef.current !== null) {
window.clearTimeout(animationTimeoutRef.current);
}
if (lastMoveColumn === null || lastMoveRow === null) {
setAnimatingMove(null);
return;
}
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
const dropDurationMs = prefersReducedMotion ? 0 : CHIP_DROP_ANIMATION_MS;
setAnimatingMove({ column: lastMoveColumn, row: lastMoveRow });
animationTimeoutRef.current = window.setTimeout(() => {
const sounds = chipDropSoundsRef.current;
const sound = sounds[Math.floor(Math.random() * sounds.length)];
if (sound) {
sound.currentTime = 0;
void sound.play().catch(() => {
// Ignore autoplay failures until the user has interacted.
});
}
setAnimatingMove(null);
animationTimeoutRef.current = null;
}, dropDurationMs);
return () => {
if (animationTimeoutRef.current !== null) {
window.clearTimeout(animationTimeoutRef.current);
}
};
}, [lastMoveColumn, lastMoveRow]);
return (
<div className="flex flex-col items-center gap-3">
{/* Player legend */}
{player1 && player2 && (
<div className="flex items-center gap-6 text-sm">
<div
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-colors ${
currentTurnColor === 1
? "border-red-500 bg-red-950/50 text-red-300"
: "border-gray-700 bg-gray-900 text-gray-400"
}`}
>
{currentTurnColor === 1 ? (
<div className="w-3.5 h-3.5 rounded-full bg-red-500 shrink-0 animate-pulse" />
) : (
<div className="w-3.5 h-3.5 rounded-full bg-red-500 shrink-0" />
)}
<span className="font-medium">{player1}</span>
</div>
<span className="text-gray-600 font-bold">vs</span>
<div
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-colors ${
currentTurnColor === 2
? "border-yellow-500 bg-yellow-950/50 text-yellow-300"
: "border-gray-700 bg-gray-900 text-gray-400"
}`}
>
{currentTurnColor === 2 ? (
<div className="w-3.5 h-3.5 rounded-full bg-yellow-400 shrink-0 animate-pulse" />
) : (
<div className="w-3.5 h-3.5 rounded-full bg-yellow-400 shrink-0" />
)}
<span className="font-medium">{player2}</span>
</div>
</div>
)}
{/* Board */}
<div className="inline-block bg-blue-800 p-3 rounded-2xl shadow-2xl border-2 border-blue-600">
<div className="flex gap-2">
{Array.from({ length: 7 }, (_, col) => (
<div
key={col}
role={canInteract ? "button" : undefined}
aria-label={canInteract ? `Drop in column ${col}` : undefined}
className={`flex flex-col gap-2 rounded-xl p-1 transition-colors ${
canInteract
? "cursor-pointer hover:bg-blue-700/60"
: "cursor-default"
} ${hoveredCol === col && canInteract ? "bg-blue-700/60" : ""}`}
onClick={() => canInteract && onColumnClick?.(col)}
onMouseEnter={() => canInteract && setHoveredCol(col)}
onMouseLeave={() => setHoveredCol(null)}
>
{/* Drop arrow indicator */}
<div
className={`h-2 flex items-center justify-center transition-opacity ${
hoveredCol === col && canInteract
? "opacity-100"
: "opacity-0"
}`}
>
<div className="w-0 h-0 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-white/70" />
</div>
{/* Cells (top row first) */}
{Array.from({ length: 6 }, (_, rowIdx) => {
const row = 5 - rowIdx;
const cell = board[col][row];
const isLast =
lastMove?.column === col && lastMove?.row === row;
const isAnimatingDrop =
animatingMove?.column === col && animatingMove?.row === row;
const chipScale = isLast ? 1.1 : 1;
const dropDistance = `${(5 - row) * 56 + 16}px`;
return (
<div
key={row}
className={`relative w-12 h-12 rounded-full border-2 bg-slate-950 overflow-visible ${
cell === 0
? `border-slate-800 ${
hoveredCol === col && canInteract
? "border-blue-400/50"
: ""
}`
: isLast
? "border-white"
: "border-slate-900"
}`}
>
{cell !== 0 && (
<div
className={`absolute inset-0 rounded-full border-2 shadow-lg transition-transform duration-150 ${
cell === 1
? `bg-red-500 border-red-700 shadow-red-950/60`
: `bg-yellow-400 border-yellow-600 shadow-yellow-950/60`
} ${isAnimatingDrop ? "chip-drop" : ""}`}
style={
{
"--chip-drop-distance": `-${dropDistance}`,
"--chip-scale": chipScale,
transform: isAnimatingDrop
? undefined
: `scale(${chipScale})`,
} as CSSProperties
}
/>
)}
</div>
);
})}
</div>
))}
</div>
{/* Column numbers */}
<div className="flex gap-2 mt-1">
{Array.from({ length: 7 }, (_, col) => (
<div
key={col}
className="w-14 p-1 text-center text-xs text-blue-400/70 font-mono"
>
{col + 1}
</div>
))}
</div>
</div>
<style jsx>{`
.chip-drop {
animation: chip-drop ${CHIP_DROP_ANIMATION_MS}ms cubic-bezier(0.22, 1, 0.36, 1)
forwards;
will-change: transform;
}
@keyframes chip-drop {
from {
transform: translateY(var(--chip-drop-distance))
scale(var(--chip-scale));
}
to {
transform: translateY(0) scale(var(--chip-scale));
}
}
@media (prefers-reduced-motion: reduce) {
.chip-drop {
animation: none;
}
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,74 @@
"use client";
import { useEffect, useState } from "react";
import Confetti from "react-confetti";
import { useConnection } from "@/lib/connection";
export default function Celebration() {
const { subscribe } = useConnection();
const [winner, setWinner] = useState<string | null>(null);
const [viewport, setViewport] = useState({ width: 0, height: 0 });
useEffect(() => {
const updateViewport = () => {
setViewport({
width: window.innerWidth,
height: window.innerHeight,
});
};
updateViewport();
window.addEventListener("resize", updateViewport);
return () => window.removeEventListener("resize", updateViewport);
}, []);
useEffect(() => {
const unsubscribe = subscribe((msg) => {
if (msg.type !== "TOURNAMENT_WINNER") return;
setWinner(msg.username || "Unknown player");
});
return () => {
unsubscribe();
};
}, [subscribe]);
if (!winner) return null;
return (
<>
<Confetti
width={viewport.width}
height={viewport.height}
recycle={false}
numberOfPieces={600}
gravity={0.2}
className="pointer-events-none fixed! inset-0! z-90!"
/>
<div className="pointer-events-none fixed inset-0 z-100 flex items-center justify-center px-4">
<div className="pointer-events-auto w-full max-w-xl rounded-3xl border border-amber-300/40 bg-linear-to-br from-amber-300 via-yellow-200 to-orange-300 p-1px shadow-2xl shadow-amber-950/60">
<div className="rounded-[calc(1.5rem-1px)] bg-slate-950/95 px-8 py-10 text-center backdrop-blur">
<div className="text-5xl">🏆</div>
<p className="mt-4 text-sm font-semibold uppercase tracking-[0.35em] text-amber-200/80">
Tournament Winner
</p>
<h2 className="mt-3 text-4xl font-black tracking-tight text-white sm:text-5xl">
{winner}
</h2>
<p className="mt-4 text-base text-amber-100/85">
Dominated and closed out the tournament.
</p>
<button
type="button"
onClick={() => setWinner(null)}
className="mt-7 inline-flex items-center justify-center rounded-full border border-amber-200/30 bg-amber-300/15 px-5 py-2 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/25"
>
Close
</button>
</div>
</div>
</div>
</>
);
}

174
components/Nav.tsx Normal file
View File

@@ -0,0 +1,174 @@
"use client";
import Link from "next/link";
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";
export default function Nav() {
const pathname = usePathname();
const router = useRouter();
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";
useEffect(() => {
if (isConnectionPage || (status === "disconnected" && shouldRedirectToConnect)) {
setShowSettingsModal(false);
}
}, [isConnectionPage, status, shouldRedirectToConnect]);
const handleBecomePlayer = (event: SubmitEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmed = nextUsername.trim();
if (!trimmed) return;
becomePlayer(trimmed);
setShowPlayerModal(false);
router.push("/play");
};
return (
<>
<nav className="bg-gray-900 border-b border-gray-800 px-4 py-3">
<div className="max-w-7xl mx-auto flex items-center gap-4 flex-wrap">
<Link
href="/"
className="text-lg font-bold text-white flex items-center gap-2"
>
<span className="text-2xl">🔴</span>
<span>Connect4 Observer</span>
</Link>
<div className="ml-auto flex items-center gap-2">
{!isConnectionPage && (
<>
{role !== "player" && !isAdmin && (
<button
onClick={() => {
setNextUsername(username);
setShowPlayerModal(true);
}}
disabled={disableRoleSwitch}
className="px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 text-white"
>
Become Player
</button>
)}
<button
onClick={disconnect}
className="px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-gray-700 hover:bg-red-600 text-white"
>
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>
</div>
</nav>
{showPlayerModal && (
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center px-4">
<form
onSubmit={handleBecomePlayer}
className="w-full max-w-sm bg-gray-900 border border-gray-700 rounded-xl p-5 flex flex-col gap-3"
>
<h2 className="text-lg font-semibold text-white">Become Player</h2>
<p className="text-sm text-gray-400">
Enter a username to connect as a player.
</p>
<input
autoFocus
value={nextUsername}
onChange={(event) => setNextUsername(event.target.value)}
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none"
placeholder="Username"
/>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={() => setShowPlayerModal(false)}
className="px-3 py-2 rounded-lg text-sm font-medium bg-gray-700 hover:bg-gray-600 text-white"
>
Cancel
</button>
<button
type="submit"
className="px-3 py-2 rounded-lg text-sm font-semibold bg-blue-600 hover:bg-blue-500 text-white"
>
Continue
</button>
</div>
</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>
)}
</>
);
}