mirror of
https://github.com/randyjc/Rahoot.git
synced 2026-03-13 20:15:35 +01:00
adding new clients view for manager page
This commit is contained in:
30
README.md
30
README.md
@@ -53,19 +53,16 @@ docker run -d \
|
|||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-p 3001:3001 \
|
-p 3001:3001 \
|
||||||
-v ./config:/app/config \
|
-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 WEB_ORIGIN=http://localhost:3000 \
|
||||||
-e SOCKET_URL=http://localhost:3001 \
|
-e SOCKET_URL=http://localhost:3001 \
|
||||||
ralex91/rahoot:latest
|
ralex91/rahoot:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
**Configuration Volume:**
|
**Configuration & Media Volume:**
|
||||||
The `-v ./config:/app/config` option mounts a local `config` folder to persist your game settings and quizzes. This allows you to:
|
- `-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.
|
||||||
- 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.
|
|
||||||
|
|
||||||
The application will be available at:
|
The application will be available at:
|
||||||
|
|
||||||
@@ -150,16 +147,29 @@ Quiz Options:
|
|||||||
- `question`: The question text
|
- `question`: The question text
|
||||||
- `answers`: Array of possible answers (2-4 options)
|
- `answers`: Array of possible answers (2-4 options)
|
||||||
- `image`: Optional URL for question image (legacy; use `media` for new content)
|
- `image`: Optional URL for question image (legacy; use `media` for new content)
|
||||||
- `media`: Optional media attachment `{ "type": "image" | "audio" | "video" | "youtube", "url": "<link>" }`. Examples:
|
- `media`: Optional media attachment `{ "type": "image" | "audio" | "video", "url": "<link>" }`. Examples:
|
||||||
- `{"type":"audio","url":"https://.../clip.mp3"}`
|
- `{"type":"audio","url":"https://.../clip.mp3"}`
|
||||||
- `{"type":"video","url":"https://.../clip.mp4"}`
|
- `{"type":"video","url":"https://.../clip.mp4"}`
|
||||||
- `{"type":"youtube","url":"https://youtu.be/dQw4w9WgXcQ"}`
|
|
||||||
- `solution`: Index of correct answer (0-based)
|
- `solution`: Index of correct answer (0-based)
|
||||||
- `cooldown`: Time in seconds before showing the question
|
- `cooldown`: Time in seconds before showing the question
|
||||||
- `time`: Time in seconds allowed to answer
|
- `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”).
|
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
|
## 🎮 How to Play
|
||||||
|
|
||||||
1. Access the manager interface at http://localhost:3000/manager
|
1. Access the manager interface at http://localhost:3000/manager
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export interface ServerToClientEvents {
|
|||||||
}) => void
|
}) => void
|
||||||
"manager:newPlayer": (_player: Player) => void
|
"manager:newPlayer": (_player: Player) => void
|
||||||
"manager:removePlayer": (_playerId: string) => void
|
"manager:removePlayer": (_playerId: string) => void
|
||||||
|
"manager:players": (_players: Player[]) => void
|
||||||
"manager:errorMessage": (_message: string) => void
|
"manager:errorMessage": (_message: string) => void
|
||||||
"manager:playerKicked": (_playerId: string) => void
|
"manager:playerKicked": (_playerId: string) => void
|
||||||
"manager:quizzLoaded": (_quizz: QuizzWithId) => void
|
"manager:quizzLoaded": (_quizz: QuizzWithId) => void
|
||||||
@@ -77,6 +78,7 @@ export interface ClientToServerEvents {
|
|||||||
"manager:abortQuiz": (_message: MessageGameId) => void
|
"manager:abortQuiz": (_message: MessageGameId) => void
|
||||||
"manager:pauseCooldown": (_message: MessageGameId) => void
|
"manager:pauseCooldown": (_message: MessageGameId) => void
|
||||||
"manager:resumeCooldown": (_message: MessageGameId) => void
|
"manager:resumeCooldown": (_message: MessageGameId) => void
|
||||||
|
"manager:endGame": (_message: MessageGameId) => void
|
||||||
"manager:skipQuestionIntro": (_message: MessageGameId) => void
|
"manager:skipQuestionIntro": (_message: MessageGameId) => void
|
||||||
"manager:nextQuestion": (_message: MessageGameId) => void
|
"manager:nextQuestion": (_message: MessageGameId) => void
|
||||||
"manager:deleteQuizz": (_message: { id: string }) => void
|
"manager:deleteQuizz": (_message: { id: string }) => void
|
||||||
|
|||||||
@@ -234,6 +234,10 @@ io.on("connection", (socket) => {
|
|||||||
withGame(gameId, socket, (game) => game.resumeCooldown(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 }) =>
|
socket.on("manager:nextQuestion", ({ gameId }) =>
|
||||||
withGame(gameId, socket, (game) => game.nextRound(socket))
|
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)
|
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:removePlayer", player.id)
|
||||||
|
io.to(game.manager.id).emit("manager:players", game.players)
|
||||||
io.to(game.gameId).emit("game:totalPlayers", game.players.length)
|
io.to(game.gameId).emit("game:totalPlayers", game.players.length)
|
||||||
|
|
||||||
console.log(`Removed player ${player.username} from game ${game.gameId}`)
|
console.log(`Removed player ${player.username} from game ${game.gameId}`)
|
||||||
@@ -290,6 +295,7 @@ io.on("connection", (socket) => {
|
|||||||
|
|
||||||
player.connected = false
|
player.connected = false
|
||||||
io.to(game.gameId).emit("game:totalPlayers", game.players.length)
|
io.to(game.gameId).emit("game:totalPlayers", game.players.length)
|
||||||
|
io.to(game.manager.id).emit("manager:players", game.players)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ class Game {
|
|||||||
this.players.push(playerData)
|
this.players.push(playerData)
|
||||||
|
|
||||||
this.io.to(this.manager.id).emit("manager:newPlayer", 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)
|
this.io.to(this.gameId).emit("game:totalPlayers", this.players.length)
|
||||||
|
|
||||||
socket.emit("game:successJoin", this.gameId)
|
socket.emit("game:successJoin", this.gameId)
|
||||||
@@ -260,6 +261,7 @@ class Game {
|
|||||||
.to(player.id)
|
.to(player.id)
|
||||||
.emit("game:reset", "You have been kicked by the manager")
|
.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: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)
|
this.io.to(this.gameId).emit("game:totalPlayers", this.players.length)
|
||||||
}
|
}
|
||||||
@@ -273,6 +275,7 @@ class Game {
|
|||||||
} else {
|
} else {
|
||||||
this.reconnectPlayer(socket)
|
this.reconnectPlayer(socket)
|
||||||
}
|
}
|
||||||
|
this.io.to(this.manager.id).emit("manager:players", this.players)
|
||||||
}
|
}
|
||||||
|
|
||||||
private reconnectManager(socket: Socket) {
|
private reconnectManager(socket: Socket) {
|
||||||
@@ -338,6 +341,7 @@ class Game {
|
|||||||
this.playerStatus.delete(oldSocketId)
|
this.playerStatus.delete(oldSocketId)
|
||||||
this.playerStatus.set(socket.id, oldStatus)
|
this.playerStatus.set(socket.id, oldStatus)
|
||||||
}
|
}
|
||||||
|
this.io.to(this.manager.id).emit("manager:players", this.players)
|
||||||
|
|
||||||
socket.emit("player:successReconnect", {
|
socket.emit("player:successReconnect", {
|
||||||
gameId: this.gameId,
|
gameId: this.gameId,
|
||||||
@@ -697,6 +701,16 @@ class Game {
|
|||||||
this.tempOldLeaderboard = null
|
this.tempOldLeaderboard = null
|
||||||
this.persist()
|
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
|
export default Game
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const ManagerGame = () => {
|
|||||||
useManagerStore()
|
useManagerStore()
|
||||||
const { setQuestionStates } = useQuestionStore()
|
const { setQuestionStates } = useQuestionStore()
|
||||||
const [cooldownPaused, setCooldownPaused] = useState(false)
|
const [cooldownPaused, setCooldownPaused] = useState(false)
|
||||||
|
const { players } = useManagerStore()
|
||||||
|
|
||||||
useEvent("game:status", ({ name, data }) => {
|
useEvent("game:status", ({ name, data }) => {
|
||||||
if (name in GAME_STATE_COMPONENTS_MANAGER) {
|
if (name in GAME_STATE_COMPONENTS_MANAGER) {
|
||||||
@@ -56,6 +57,18 @@ const ManagerGame = () => {
|
|||||||
toast.error(message)
|
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) => {
|
useEvent("game:cooldownPause", (isPaused) => {
|
||||||
setCooldownPaused(isPaused)
|
setCooldownPaused(isPaused)
|
||||||
})
|
})
|
||||||
@@ -102,6 +115,11 @@ const ManagerGame = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEndGame = () => {
|
||||||
|
if (!gameId) return
|
||||||
|
socket?.emit("manager:endGame", { gameId })
|
||||||
|
}
|
||||||
|
|
||||||
let component = null
|
let component = null
|
||||||
|
|
||||||
switch (status?.name) {
|
switch (status?.name) {
|
||||||
@@ -155,6 +173,8 @@ const ManagerGame = () => {
|
|||||||
showPause={
|
showPause={
|
||||||
status?.name === STATUS.SHOW_QUESTION || status?.name === STATUS.SELECT_ANSWER
|
status?.name === STATUS.SHOW_QUESTION || status?.name === STATUS.SELECT_ANSWER
|
||||||
}
|
}
|
||||||
|
onEnd={handleEndGame}
|
||||||
|
players={players}
|
||||||
manager
|
manager
|
||||||
>
|
>
|
||||||
{component}
|
{component}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ type Props = PropsWithChildren & {
|
|||||||
onPause?: () => void
|
onPause?: () => void
|
||||||
paused?: boolean
|
paused?: boolean
|
||||||
showPause?: boolean
|
showPause?: boolean
|
||||||
|
onEnd?: () => void
|
||||||
|
players?: { id: string; username: string; connected: boolean }[]
|
||||||
manager?: boolean
|
manager?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +30,8 @@ const GameWrapper = ({
|
|||||||
onPause,
|
onPause,
|
||||||
paused,
|
paused,
|
||||||
showPause,
|
showPause,
|
||||||
|
onEnd,
|
||||||
|
players,
|
||||||
manager,
|
manager,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { isConnected } = useSocket()
|
const { isConnected } = useSocket()
|
||||||
@@ -97,8 +101,37 @@ const GameWrapper = ({
|
|||||||
{paused ? "Resume" : "Pause"}
|
{paused ? "Resume" : "Pause"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{manager && onEnd && (
|
||||||
|
<Button className="self-end bg-red-600 px-4" onClick={onEnd}>
|
||||||
|
End game
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{manager && players && players.length > 0 && (
|
||||||
|
<div className="mx-4 mb-2 rounded-md bg-white/90 p-3 text-sm shadow">
|
||||||
|
<div className="mb-1 text-xs font-semibold uppercase text-gray-600">
|
||||||
|
Players ({players.length})
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{players.map((p) => (
|
||||||
|
<span
|
||||||
|
key={p.id}
|
||||||
|
className={clsx(
|
||||||
|
"rounded border px-2 py-1 font-semibold",
|
||||||
|
p.connected
|
||||||
|
? "border-green-500 text-green-700"
|
||||||
|
: "border-gray-300 text-gray-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.username || p.id} {p.connected ? "" : "(disc.)"}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
{!manager && (
|
{!manager && (
|
||||||
|
|||||||
Reference in New Issue
Block a user