Compare commits
2
Commits
v2.0.0-rc4
..
v2.0.1
No files matched your search
@@ -0,0 +1,5 @@
|
||||
.git
|
||||
.next
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
tsconfig.tsbuildinfo
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"next/typescript"
|
||||
]
|
||||
}
|
||||
+3
-3
@@ -3,9 +3,9 @@
|
||||
# ============================================
|
||||
|
||||
# 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.
|
||||
ARG NODE_VERSION=24.13.0-slim
|
||||
ARG NODE_VERSION=24.22.1-slim
|
||||
|
||||
FROM node:${NODE_VERSION} AS dependencies
|
||||
|
||||
@@ -13,7 +13,7 @@ FROM node:${NODE_VERSION} AS dependencies
|
||||
WORKDIR /app
|
||||
|
||||
# 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
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
|
||||
@@ -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
|
||||
+15
-24
@@ -49,6 +49,20 @@ export default function PlayPage() {
|
||||
const myColorRef = useRef<1 | 2 | null>(null);
|
||||
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(() => {
|
||||
setBoard(createEmptyBoard());
|
||||
setLastMove(null);
|
||||
@@ -86,30 +100,7 @@ export default function PlayPage() {
|
||||
router.replace("/spectate");
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === "connected" && gamePhase === "idle") {
|
||||
// Mid-match reconnect can remount with phase idle while still in a match; avoid
|
||||
// the pre-queue "connected" / Ready Up state until we know we are not in-game.
|
||||
setGamePhase(isInMatch ? "playing" : "connected");
|
||||
if (isInMatch) {
|
||||
const color: 1 | 2 = shouldGoFirst ? 1 : 2;
|
||||
setMyColor(color);
|
||||
myColorRef.current = color;
|
||||
|
||||
setIsMyTurn(shouldGoFirst);
|
||||
isMyTurnRef.current = shouldGoFirst;
|
||||
}
|
||||
|
||||
}
|
||||
}, [
|
||||
role,
|
||||
status,
|
||||
router,
|
||||
gamePhase,
|
||||
isInMatch,
|
||||
shouldRedirectToConnect,
|
||||
clearRedirectFlag,
|
||||
]);
|
||||
}, [role, status, router, shouldRedirectToConnect, clearRedirectFlag]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribe((msg: ParsedMessage) => {
|
||||
|
||||
+18
-16
@@ -30,12 +30,27 @@ export default function Board({
|
||||
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;
|
||||
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(() => {
|
||||
chipDropSoundsRef.current = CHIP_DROP_SOUND_PATHS.map((path) => {
|
||||
@@ -54,26 +69,13 @@ export default function Board({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (animationTimeoutRef.current !== null) {
|
||||
window.clearTimeout(animationTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (lastMoveColumn === null || lastMoveRow === null) {
|
||||
setAnimatingMove(null);
|
||||
return;
|
||||
}
|
||||
if (animatingMove === 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)];
|
||||
@@ -94,7 +96,7 @@ export default function Board({
|
||||
window.clearTimeout(animationTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [lastMoveColumn, lastMoveRow]);
|
||||
}, [animatingMove]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
|
||||
+7
-6
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { SubmitEvent, useEffect, useState } from "react";
|
||||
import { SubmitEvent, useState } from "react";
|
||||
import { Settings, X } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
@@ -28,11 +28,12 @@ export default function Nav() {
|
||||
const disableRoleSwitch =
|
||||
status === "connecting" || status === "reconnecting";
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnectionPage || (status === "disconnected" && shouldRedirectToConnect)) {
|
||||
setShowSettingsModal(false);
|
||||
}
|
||||
}, [isConnectionPage, status, shouldRedirectToConnect]);
|
||||
if (
|
||||
showSettingsModal &&
|
||||
(isConnectionPage || (status === "disconnected" && shouldRedirectToConnect))
|
||||
) {
|
||||
setShowSettingsModal(false);
|
||||
}
|
||||
|
||||
const handleBecomePlayer = (event: SubmitEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -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"]),
|
||||
]);
|
||||
Vendored
+2
-1
@@ -1,6 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
Generated
+456
-357
File diff suppressed because it is too large.
Load diff
+3
-3
@@ -6,13 +6,13 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "16.2.4",
|
||||
"next": "16.4.2",
|
||||
"react": "19.2.5",
|
||||
"react-confetti": "^6.4.0",
|
||||
"react-dom": "19.2.5"
|
||||
@@ -23,7 +23,7 @@
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"eslint-config-next": "16.4.2",
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.8.1",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
Reference in new issue
Block a user