Compare commits

..
5 Commits
15 changed files with 1253 additions and 466 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
.git
.next
node_modules
npm-debug.log*
tsconfig.tsbuildinfo
-6
View File
@@ -1,6 +0,0 @@
{
"extends": [
"next/core-web-vitals",
"next/typescript"
]
}
+4 -4
View File
@@ -3,9 +3,9 @@
# ============================================ # ============================================
# IMPORTANT: Node.js Version Maintenance # IMPORTANT: Node.js Version Maintenance
# This Dockerfile uses Node.js 24.13.0-slim, which was the latest LTS version at the time of writing. # This Dockerfile uses Node.js 24.22.1-slim, which was the latest LTS version at the time of writing.
# To ensure security and compatibility, regularly update the NODE_VERSION ARG to the latest LTS version. # To ensure security and compatibility, regularly update the NODE_VERSION ARG to the latest LTS version.
ARG NODE_VERSION=24.13.0-slim ARG NODE_VERSION=24.22.1-slim
FROM node:${NODE_VERSION} AS dependencies FROM node:${NODE_VERSION} AS dependencies
@@ -13,7 +13,7 @@ FROM node:${NODE_VERSION} AS dependencies
WORKDIR /app WORKDIR /app
# Copy package-related files first to leverage Docker's caching mechanism # Copy package-related files first to leverage Docker's caching mechanism
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
# Install project dependencies with frozen lockfile for reproducible builds # Install project dependencies with frozen lockfile for reproducible builds
RUN --mount=type=cache,target=/root/.npm \ RUN --mount=type=cache,target=/root/.npm \
@@ -109,4 +109,4 @@ USER node
EXPOSE 3000 EXPOSE 3000
# Start Next.js standalone server # Start Next.js standalone server
CMD ["node", "server.js"] CMD ["node", "server.js"]
+103
View File
@@ -0,0 +1,103 @@
# Connect4 Moderator Observer UI
A website interface for connecting to the [Connect4 WebSocket server](https://github.com/joshuafhiggins/connect4-moderator-server) as an observer or player, watching live matches, and managing tournaments.
## Prerequisites
- Linux/macOS terminal or Windows PowerShell
- Git
- Docker (optional, for containerized runs)
## Run Locally (Node.js + npm)
### 1) Install Node.js (includes npm)
#### Option A: Install with `nvm` (recommended, including Windows support)
On Linux/macOS:
```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
source ~/.zshrc
nvm install --lts
nvm use --lts
node -v
npm -v
```
On Windows (PowerShell), use `nvm-windows`:
```powershell
winget install CoreyButler.NVMforWindows
nvm install lts
nvm use lts
node -v
npm -v
```
### 2) Install dependencies
From the project root:
```bash
npm i
```
### 3) Start the UI in development mode
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000).
## Run with Docker
This repository includes:
- `Dockerfile` (multi-stage production build)
- `docker-compose.yml`
- `docker_build.sh`
### Build image directly from `Dockerfile`
```bash
docker build . -t joshuafhiggins/connect4-ui
```
or use the provided script:
```bash
./docker_build.sh
```
### Run container directly
```bash
docker run --rm -p 3000:3000 --name connect4-ui joshuafhiggins/connect4-ui
```
### Run with Docker Compose
```bash
docker compose up --build
```
Run detached:
```bash
docker compose up -d --build
```
Stop:
```bash
docker compose down
```
## Useful npm Scripts
- `npm run dev` - start Next.js dev server
- `npm run build` - build for production
- `npm run start` - run production build
- `npm run lint` - lint the project
-1
View File
@@ -7,7 +7,6 @@ import { ConnectionProvider } from "@/lib/connection";
import { CHIP_DROP_SOUND_PATHS } from "@/lib/sfx"; import { CHIP_DROP_SOUND_PATHS } from "@/lib/sfx";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Connect4 RPI Minds & Machines",
description: "Watch matches, track tournaments, and play Connect4", description: "Watch matches, track tournaments, and play Connect4",
icons: { icons: {
icon: "/favicon.ico", icon: "/favicon.ico",
+23 -14
View File
@@ -23,6 +23,8 @@ export default function PlayPage() {
role, role,
username, username,
status, status,
isInMatch,
shouldGoFirst,
send, send,
subscribe, subscribe,
reconnectAttempts, reconnectAttempts,
@@ -47,6 +49,20 @@ export default function PlayPage() {
const myColorRef = useRef<1 | 2 | null>(null); const myColorRef = useRef<1 | 2 | null>(null);
const isMyTurnRef = useRef(false); const isMyTurnRef = useRef(false);
// Restore match state before showing Ready Up after a mid-match reconnect.
if (role === "player" && status === "connected" && gamePhase === "idle") {
setGamePhase(isInMatch ? "playing" : "connected");
if (isInMatch) {
setMyColor(shouldGoFirst ? 1 : 2);
setIsMyTurn(shouldGoFirst);
}
}
useEffect(() => {
myColorRef.current = myColor;
isMyTurnRef.current = isMyTurn;
}, [myColor, isMyTurn]);
const resetGame = useCallback(() => { const resetGame = useCallback(() => {
setBoard(createEmptyBoard()); setBoard(createEmptyBoard());
setLastMove(null); setLastMove(null);
@@ -84,25 +100,18 @@ export default function PlayPage() {
router.replace("/spectate"); router.replace("/spectate");
return; return;
} }
}, [role, status, router, shouldRedirectToConnect, clearRedirectFlag]);
if (status === "connected" && gamePhase === "idle") {
setGamePhase("connected");
}
}, [
role,
status,
router,
gamePhase,
shouldRedirectToConnect,
clearRedirectFlag,
]);
useEffect(() => { useEffect(() => {
const unsubscribe = subscribe((msg: ParsedMessage) => { const unsubscribe = subscribe((msg: ParsedMessage) => {
switch (msg.type) { switch (msg.type) {
case "CONNECT_ACK": case "CONNECT_ACK":
case "RECONNECT_ACK": case "RECONNECT_ACK":
setGamePhase((prev) => (prev === "idle" ? "connected" : prev)); setGamePhase((prev) => {
if (prev !== "idle") return prev;
if (isInMatch) return "playing";
return "connected";
});
break; break;
case "ERROR": case "ERROR":
@@ -193,7 +202,7 @@ export default function PlayPage() {
}); });
return unsubscribe; return unsubscribe;
}, [resetGame, send, subscribe]); }, [resetGame, send, subscribe, isInMatch, username]);
const handleColumnClick = useCallback( const handleColumnClick = useCallback(
(col: number) => { (col: number) => {
+1
View File
@@ -152,6 +152,7 @@ export default function AdminSettingsPanel() {
break; break;
case "RESERVATION_LIST": case "RESERVATION_LIST":
setReservations(message.reservations); setReservations(message.reservations);
setActionFeedback("Loaded reservations.");
break; break;
case "RESERVATION_ADD": case "RESERVATION_ADD":
setReservations((prev) => { setReservations((prev) => {
+18 -16
View File
@@ -30,12 +30,27 @@ export default function Board({
column: number; column: number;
row: number; row: number;
} | null>(null); } | null>(null);
const isFirstRender = useRef(true);
const animationTimeoutRef = useRef<number | null>(null); const animationTimeoutRef = useRef<number | null>(null);
const chipDropSoundsRef = useRef<HTMLAudioElement[]>([]); const chipDropSoundsRef = useRef<HTMLAudioElement[]>([]);
const canInteract = !disabled && !!onColumnClick; const canInteract = !disabled && !!onColumnClick;
const lastMoveColumn = lastMove?.column ?? null; const lastMoveColumn = lastMove?.column ?? null;
const lastMoveRow = lastMove?.row ?? null; const lastMoveRow = lastMove?.row ?? null;
const [previousMove, setPreviousMove] = useState({
column: lastMoveColumn,
row: lastMoveRow,
});
if (
previousMove.column !== lastMoveColumn ||
previousMove.row !== lastMoveRow
) {
setPreviousMove({ column: lastMoveColumn, row: lastMoveRow });
setAnimatingMove(
lastMoveColumn === null || lastMoveRow === null
? null
: { column: lastMoveColumn, row: lastMoveRow },
);
}
useEffect(() => { useEffect(() => {
chipDropSoundsRef.current = CHIP_DROP_SOUND_PATHS.map((path) => { chipDropSoundsRef.current = CHIP_DROP_SOUND_PATHS.map((path) => {
@@ -54,26 +69,13 @@ export default function Board({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (isFirstRender.current) { if (animatingMove === null) return;
isFirstRender.current = false;
return;
}
if (animationTimeoutRef.current !== null) {
window.clearTimeout(animationTimeoutRef.current);
}
if (lastMoveColumn === null || lastMoveRow === null) {
setAnimatingMove(null);
return;
}
const prefersReducedMotion = window.matchMedia( const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)", "(prefers-reduced-motion: reduce)",
).matches; ).matches;
const dropDurationMs = prefersReducedMotion ? 0 : CHIP_DROP_ANIMATION_MS; const dropDurationMs = prefersReducedMotion ? 0 : CHIP_DROP_ANIMATION_MS;
setAnimatingMove({ column: lastMoveColumn, row: lastMoveRow });
animationTimeoutRef.current = window.setTimeout(() => { animationTimeoutRef.current = window.setTimeout(() => {
const sounds = chipDropSoundsRef.current; const sounds = chipDropSoundsRef.current;
const sound = sounds[Math.floor(Math.random() * sounds.length)]; const sound = sounds[Math.floor(Math.random() * sounds.length)];
@@ -94,7 +96,7 @@ export default function Board({
window.clearTimeout(animationTimeoutRef.current); window.clearTimeout(animationTimeoutRef.current);
} }
}; };
}, [lastMoveColumn, lastMoveRow]); }, [animatingMove]);
return ( return (
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
+7 -6
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { SubmitEvent, useEffect, useState } from "react"; import { SubmitEvent, useState } from "react";
import { Settings, X } from "lucide-react"; import { Settings, X } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
@@ -28,11 +28,12 @@ export default function Nav() {
const disableRoleSwitch = const disableRoleSwitch =
status === "connecting" || status === "reconnecting"; status === "connecting" || status === "reconnecting";
useEffect(() => { if (
if (isConnectionPage || (status === "disconnected" && shouldRedirectToConnect)) { showSettingsModal &&
setShowSettingsModal(false); (isConnectionPage || (status === "disconnected" && shouldRedirectToConnect))
} ) {
}, [isConnectionPage, status, shouldRedirectToConnect]); setShowSettingsModal(false);
}
const handleBecomePlayer = (event: SubmitEvent<HTMLFormElement>) => { const handleBecomePlayer = (event: SubmitEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
export default defineConfig([
...nextVitals,
...nextTs,
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
]);
+7
View File
@@ -40,6 +40,7 @@ interface ConnectionContextValue {
username: string; username: string;
status: ConnectionStatus; status: ConnectionStatus;
isInMatch: boolean; isInMatch: boolean;
shouldGoFirst: boolean;
isAdmin: boolean; isAdmin: boolean;
reconnectAttempts: number; reconnectAttempts: number;
shouldRedirectToConnect: boolean; shouldRedirectToConnect: boolean;
@@ -70,6 +71,7 @@ export function ConnectionProvider({
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [status, setStatus] = useState<ConnectionStatus>("idle"); const [status, setStatus] = useState<ConnectionStatus>("idle");
const [isInMatch, setIsInMatch] = useState(false); const [isInMatch, setIsInMatch] = useState(false);
const [shouldGoFirst, setShouldGoFirst] = useState(false);
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [reconnectAttempts, setReconnectAttempts] = useState(0); const [reconnectAttempts, setReconnectAttempts] = useState(0);
const [shouldRedirectToConnect, setShouldRedirectToConnect] = useState(false); const [shouldRedirectToConnect, setShouldRedirectToConnect] = useState(false);
@@ -81,6 +83,7 @@ export function ConnectionProvider({
const reconnectDeadlineRef = useRef<number | null>(null); const reconnectDeadlineRef = useRef<number | null>(null);
const reconnectActiveRef = useRef(false); const reconnectActiveRef = useRef(false);
const isInMatchRef = useRef(false); const isInMatchRef = useRef(false);
const shouldGoFirstRef = useRef(false);
const sessionRef = useRef<SessionState | null>(null); const sessionRef = useRef<SessionState | null>(null);
const clearReconnectTimer = useCallback(() => { const clearReconnectTimer = useCallback(() => {
@@ -202,6 +205,8 @@ export function ConnectionProvider({
if (parsed.type === "GAME_START") { if (parsed.type === "GAME_START") {
isInMatchRef.current = true; isInMatchRef.current = true;
setIsInMatch(true); setIsInMatch(true);
shouldGoFirstRef.current = parsed.goesFirst;
setShouldGoFirst(parsed.goesFirst);
} }
if ( if (
@@ -377,6 +382,7 @@ export function ConnectionProvider({
username, username,
status, status,
isInMatch, isInMatch,
shouldGoFirst,
isAdmin, isAdmin,
reconnectAttempts, reconnectAttempts,
shouldRedirectToConnect, shouldRedirectToConnect,
@@ -394,6 +400,7 @@ export function ConnectionProvider({
username, username,
status, status,
isInMatch, isInMatch,
shouldGoFirst,
isAdmin, isAdmin,
reconnectAttempts, reconnectAttempts,
shouldRedirectToConnect, shouldRedirectToConnect,
+2
View File
@@ -1,5 +1,7 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1044 -407
View File
File diff suppressed because it is too large. Load diff
+11 -7
View File
@@ -6,27 +6,31 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "eslint .",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check ." "format:check": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"next": "15.2.4", "next": "16.4.2",
"react": "^19.0.0", "react": "19.2.5",
"react-confetti": "^6.4.0", "react-confetti": "^6.4.0",
"react-dom": "^19.0.0" "react-dom": "19.2.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "19.2.14",
"@types/react-dom": "^19", "@types/react-dom": "19.2.3",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.2.4", "eslint-config-next": "16.4.2",
"postcss": "^8", "postcss": "^8",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
},
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
} }
} }
+19 -5
View File
@@ -1,6 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -10,7 +14,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
@@ -18,10 +22,20 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./*"] "@/*": [
"./*"
]
}, },
"target": "ES2017" "target": "ES2017"
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }