From d66b03e7977c242ebfb9ce6b620ae9fb8f229d8a Mon Sep 17 00:00:00 2001 From: RandyJC Date: Mon, 8 Dec 2025 23:03:46 +0100 Subject: [PATCH] adding new clients view for manager page --- README.md | 30 +++++++++++------ packages/common/src/types/game/socket.ts | 2 ++ packages/socket/src/index.ts | 6 ++++ packages/socket/src/services/game.ts | 14 ++++++++ .../src/app/game/manager/[gameId]/page.tsx | 20 +++++++++++ .../web/src/components/game/GameWrapper.tsx | 33 +++++++++++++++++++ 6 files changed, 95 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index dbfd6cc..ed9f791 100644 --- a/README.md +++ b/README.md @@ -53,19 +53,16 @@ docker run -d \ -p 3000:3000 \ -p 3001:3001 \ -v ./config:/app/config \ + -e REDIS_URL=redis://user:pass@redis:6379 \ + -e MEDIA_MAX_UPLOAD_MB=200 \ -e WEB_ORIGIN=http://localhost:3000 \ -e SOCKET_URL=http://localhost:3001 \ ralex91/rahoot:latest ``` -**Configuration Volume:** -The `-v ./config:/app/config` option mounts a local `config` folder to persist your game settings and quizzes. This allows you to: - -- Edit your configuration files directly on your host machine -- Keep your settings when updating the container -- Easily backup your quizzes and game configuration - -The folder will be created automatically on first run with an example quiz to get you started. +**Configuration & Media Volume:** +- `-v ./config:/app/config` mounts a local `config` folder to persist settings, quizzes, and uploaded media (`config/quizz`, `config/media`). This keeps your data across redeploys and lets you back it up easily. +- The folder is auto-created on first run with an example quiz. The application will be available at: @@ -150,16 +147,29 @@ Quiz Options: - `question`: The question text - `answers`: Array of possible answers (2-4 options) - `image`: Optional URL for question image (legacy; use `media` for new content) - - `media`: Optional media attachment `{ "type": "image" | "audio" | "video" | "youtube", "url": "" }`. Examples: +- `media`: Optional media attachment `{ "type": "image" | "audio" | "video", "url": "" }`. Examples: - `{"type":"audio","url":"https://.../clip.mp3"}` - `{"type":"video","url":"https://.../clip.mp4"}` - - `{"type":"youtube","url":"https://youtu.be/dQw4w9WgXcQ"}` - `solution`: Index of correct answer (0-based) - `cooldown`: Time in seconds before showing the question - `time`: Time in seconds allowed to answer Tip: You can now create and edit quizzes directly from the Manager UI (login at `/manager` and click “Manage”). +### Manager Features +- Upload image/audio/video directly in the quiz editor (stored under `config/media`). +- Manual “Set timing from media” to align cooldown/answer time with clip length. +- Media library view: see all uploads, where they’re used, and delete unused files. +- Delete quizzes from the editor. +- Pause/Resume/Skip question intro and answer timers; End Game button to reset everyone. +- Player list in manager view showing connected/disconnected players. +- Click-to-zoom images during questions. + +### Resilience & Persistence +- Redis snapshotting (set `REDIS_URL`, e.g., `redis://:password@redis:6379`) keeps game state so managers/players can reconnect without losing progress. +- Client auto-reconnects using stored `clientId` and last `gameId`; state resumes after refresh/tab close if the game still exists. +- `MEDIA_MAX_UPLOAD_MB` env controls upload size limit (default 50MB; set higher for video). + ## 🎮 How to Play 1. Access the manager interface at http://localhost:3000/manager diff --git a/packages/common/src/types/game/socket.ts b/packages/common/src/types/game/socket.ts index d7c4fcc..e194d21 100644 --- a/packages/common/src/types/game/socket.ts +++ b/packages/common/src/types/game/socket.ts @@ -60,6 +60,7 @@ export interface ServerToClientEvents { }) => void "manager:newPlayer": (_player: Player) => void "manager:removePlayer": (_playerId: string) => void + "manager:players": (_players: Player[]) => void "manager:errorMessage": (_message: string) => void "manager:playerKicked": (_playerId: string) => void "manager:quizzLoaded": (_quizz: QuizzWithId) => void @@ -77,6 +78,7 @@ export interface ClientToServerEvents { "manager:abortQuiz": (_message: MessageGameId) => void "manager:pauseCooldown": (_message: MessageGameId) => void "manager:resumeCooldown": (_message: MessageGameId) => void + "manager:endGame": (_message: MessageGameId) => void "manager:skipQuestionIntro": (_message: MessageGameId) => void "manager:nextQuestion": (_message: MessageGameId) => void "manager:deleteQuizz": (_message: { id: string }) => void diff --git a/packages/socket/src/index.ts b/packages/socket/src/index.ts index 1203a24..ff0f7f2 100644 --- a/packages/socket/src/index.ts +++ b/packages/socket/src/index.ts @@ -234,6 +234,10 @@ io.on("connection", (socket) => { withGame(gameId, socket, (game) => game.resumeCooldown(socket)) ) + socket.on("manager:endGame", ({ gameId }) => + withGame(gameId, socket, (game) => game.endGame(socket, registry)) + ) + socket.on("manager:nextQuestion", ({ gameId }) => withGame(gameId, socket, (game) => game.nextRound(socket)) ) @@ -281,6 +285,7 @@ io.on("connection", (socket) => { game.players = game.players.filter((p) => p.id !== socket.id) io.to(game.manager.id).emit("manager:removePlayer", player.id) + io.to(game.manager.id).emit("manager:players", game.players) io.to(game.gameId).emit("game:totalPlayers", game.players.length) console.log(`Removed player ${player.username} from game ${game.gameId}`) @@ -290,6 +295,7 @@ io.on("connection", (socket) => { player.connected = false io.to(game.gameId).emit("game:totalPlayers", game.players.length) + io.to(game.manager.id).emit("manager:players", game.players) }) }) diff --git a/packages/socket/src/services/game.ts b/packages/socket/src/services/game.ts index 2091f87..87a9d80 100644 --- a/packages/socket/src/services/game.ts +++ b/packages/socket/src/services/game.ts @@ -236,6 +236,7 @@ class Game { this.players.push(playerData) this.io.to(this.manager.id).emit("manager:newPlayer", playerData) + this.io.to(this.manager.id).emit("manager:players", this.players) this.io.to(this.gameId).emit("game:totalPlayers", this.players.length) socket.emit("game:successJoin", this.gameId) @@ -260,6 +261,7 @@ class Game { .to(player.id) .emit("game:reset", "You have been kicked by the manager") this.io.to(this.manager.id).emit("manager:playerKicked", player.id) + this.io.to(this.manager.id).emit("manager:players", this.players) this.io.to(this.gameId).emit("game:totalPlayers", this.players.length) } @@ -273,6 +275,7 @@ class Game { } else { this.reconnectPlayer(socket) } + this.io.to(this.manager.id).emit("manager:players", this.players) } private reconnectManager(socket: Socket) { @@ -338,6 +341,7 @@ class Game { this.playerStatus.delete(oldSocketId) this.playerStatus.set(socket.id, oldStatus) } + this.io.to(this.manager.id).emit("manager:players", this.players) socket.emit("player:successReconnect", { gameId: this.gameId, @@ -697,6 +701,16 @@ class Game { this.tempOldLeaderboard = null this.persist() } + + endGame(socket: Socket, registry: typeof Registry.prototype) { + if (socket.id !== this.manager.id) { + return + } + this.started = false + this.abortCooldown() + this.io.to(this.gameId).emit("game:reset", "Game ended by manager") + registry.removeGame(this.gameId) + } } export default Game diff --git a/packages/web/src/app/game/manager/[gameId]/page.tsx b/packages/web/src/app/game/manager/[gameId]/page.tsx index 71a0e7a..ec93f46 100644 --- a/packages/web/src/app/game/manager/[gameId]/page.tsx +++ b/packages/web/src/app/game/manager/[gameId]/page.tsx @@ -26,6 +26,7 @@ const ManagerGame = () => { useManagerStore() const { setQuestionStates } = useQuestionStore() const [cooldownPaused, setCooldownPaused] = useState(false) + const { players } = useManagerStore() useEvent("game:status", ({ name, data }) => { if (name in GAME_STATE_COMPONENTS_MANAGER) { @@ -56,6 +57,18 @@ const ManagerGame = () => { toast.error(message) }) + useEvent("manager:newPlayer", (player) => { + setPlayers((prev) => [...prev.filter((p) => p.id !== player.id), player]) + }) + + useEvent("manager:removePlayer", (playerId) => { + setPlayers((prev) => prev.filter((p) => p.id !== playerId)) + }) + + useEvent("manager:players", (players) => { + setPlayers(players) + }) + useEvent("game:cooldownPause", (isPaused) => { setCooldownPaused(isPaused) }) @@ -102,6 +115,11 @@ const ManagerGame = () => { } } + const handleEndGame = () => { + if (!gameId) return + socket?.emit("manager:endGame", { gameId }) + } + let component = null switch (status?.name) { @@ -155,6 +173,8 @@ const ManagerGame = () => { showPause={ status?.name === STATUS.SHOW_QUESTION || status?.name === STATUS.SELECT_ANSWER } + onEnd={handleEndGame} + players={players} manager > {component} diff --git a/packages/web/src/components/game/GameWrapper.tsx b/packages/web/src/components/game/GameWrapper.tsx index e51250d..50ea048 100644 --- a/packages/web/src/components/game/GameWrapper.tsx +++ b/packages/web/src/components/game/GameWrapper.tsx @@ -18,6 +18,8 @@ type Props = PropsWithChildren & { onPause?: () => void paused?: boolean showPause?: boolean + onEnd?: () => void + players?: { id: string; username: string; connected: boolean }[] manager?: boolean } @@ -28,6 +30,8 @@ const GameWrapper = ({ onPause, paused, showPause, + onEnd, + players, manager, }: Props) => { const { isConnected } = useSocket() @@ -97,8 +101,37 @@ const GameWrapper = ({ {paused ? "Resume" : "Pause"} )} + + {manager && onEnd && ( + + )} + {manager && players && players.length > 0 && ( +
+
+ Players ({players.length}) +
+
+ {players.map((p) => ( + + {p.username || p.id} {p.connected ? "" : "(disc.)"} + + ))} +
+
+ )} + {children} {!manager && (