mirror of
https://github.com/randyjc/Rahoot.git
synced 2026-03-13 20:15:35 +01:00
Compare commits
20 Commits
ce89a023c8
...
V2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55349e01f6 | ||
|
|
82be8dee93 | ||
|
|
656c4efddd | ||
|
|
8890808751 | ||
|
|
113950cc6f | ||
|
|
d8a9fb2dca | ||
|
|
3317925d08 | ||
|
|
d66b03e797 | ||
|
|
c7d41cd7a5 | ||
|
|
42df8f5893 | ||
|
|
befe39d2fd | ||
|
|
7129ec6984 | ||
|
|
e2df9c20cc | ||
|
|
01b26c59f0 | ||
|
|
03fdeb643a | ||
|
|
6420544f35 | ||
|
|
03b00b2499 | ||
|
|
ea49971609 | ||
|
|
e60818f93e | ||
|
|
5499a83a9f |
31
README.md
31
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,30 @@ 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": "<link>" }`. Examples:
|
||||
- `media`: Optional media attachment `{ "type": "image" | "audio" | "video", "url": "<link>" }`. 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.
|
||||
- Player reconnect resilience: Redis snapshotting keeps game state; clients auto-rejoin using stored `clientId`/last game; username/points are hydrated locally after refresh without a manual reload.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -8,7 +8,7 @@ export type Player = {
|
||||
|
||||
export type Answer = {
|
||||
playerId: string
|
||||
answerId: number
|
||||
answerIds: number[]
|
||||
points: number
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export type Quizz = {
|
||||
image?: string
|
||||
media?: QuestionMedia
|
||||
answers: string[]
|
||||
solution: number
|
||||
solution: number | number[]
|
||||
cooldown: number
|
||||
time: number
|
||||
}[]
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface ServerToClientEvents {
|
||||
"game:errorMessage": (_message: string) => void
|
||||
"game:startCooldown": () => void
|
||||
"game:cooldown": (_count: number) => void
|
||||
"game:cooldownPause": (_paused: boolean) => void
|
||||
"game:reset": (_message: string) => void
|
||||
"game:updateQuestion": (_data: { current: number; total: number }) => void
|
||||
"game:playerAnswer": (_count: number) => void
|
||||
@@ -59,10 +60,12 @@ 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
|
||||
"manager:quizzSaved": (_quizz: QuizzWithId) => void
|
||||
"manager:quizzDeleted": (_id: string) => void
|
||||
}
|
||||
|
||||
export interface ClientToServerEvents {
|
||||
@@ -73,7 +76,12 @@ export interface ClientToServerEvents {
|
||||
"manager:kickPlayer": (_message: { gameId: string; playerId: string }) => void
|
||||
"manager:startGame": (_message: MessageGameId) => void
|
||||
"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
|
||||
"manager:showLeaderboard": (_message: MessageGameId) => void
|
||||
"manager:getQuizz": (_quizzId: string) => void
|
||||
"manager:saveQuizz": (_payload: { id: string | null; quizz: Quizz }) => void
|
||||
@@ -83,7 +91,7 @@ export interface ClientToServerEvents {
|
||||
"player:login": (_message: MessageWithoutStatus<{ username: string }>) => void
|
||||
"player:reconnect": (_message: { gameId: string }) => void
|
||||
"player:selectedAnswer": (
|
||||
_message: MessageWithoutStatus<{ answerKey: number }>
|
||||
_message: MessageWithoutStatus<{ answerKeys: number[] }>
|
||||
) => void
|
||||
|
||||
// Common
|
||||
|
||||
@@ -31,6 +31,7 @@ export type CommonStatusDataMap = {
|
||||
media?: QuestionMedia
|
||||
time: number
|
||||
totalPlayer: number
|
||||
allowsMultiple: boolean
|
||||
}
|
||||
SHOW_RESULT: {
|
||||
correct: boolean
|
||||
@@ -49,7 +50,7 @@ type ManagerExtraStatus = {
|
||||
SHOW_RESPONSES: {
|
||||
question: string
|
||||
responses: Record<number, number>
|
||||
correct: number
|
||||
correct: number | number[]
|
||||
answers: string[]
|
||||
image?: string
|
||||
media?: QuestionMedia
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@rahoot/common": "workspace:*",
|
||||
"@t3-oss/env-core": "^0.13.8",
|
||||
"dayjs": "^1.11.18",
|
||||
"redis": "^4.6.13",
|
||||
"socket.io": "^4.8.1",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.1.12"
|
||||
|
||||
@@ -4,6 +4,7 @@ import env from "@rahoot/socket/env"
|
||||
import Config from "@rahoot/socket/services/config"
|
||||
import Game from "@rahoot/socket/services/game"
|
||||
import Registry from "@rahoot/socket/services/registry"
|
||||
import { loadSnapshot } from "@rahoot/socket/services/persistence"
|
||||
import { withGame } from "@rahoot/socket/utils/game"
|
||||
import { Server as ServerIO } from "socket.io"
|
||||
|
||||
@@ -34,6 +35,24 @@ io.on("connection", (socket) => {
|
||||
`A user connected: socketId: ${socket.id}, clientId: ${socket.handshake.auth.clientId}`
|
||||
)
|
||||
|
||||
const ensureGame = async (gameId: string) => {
|
||||
let game = registry.getGameById(gameId)
|
||||
if (game) return game
|
||||
|
||||
try {
|
||||
const snapshot = await loadSnapshot(gameId)
|
||||
if (snapshot) {
|
||||
const restored = await Game.fromSnapshot(io, snapshot)
|
||||
registry.addGame(restored)
|
||||
return restored
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to restore game", error)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
socket.on("player:reconnect", ({ gameId }) => {
|
||||
const game = registry.getPlayerGame(gameId, socket.handshake.auth.clientId)
|
||||
|
||||
@@ -43,11 +62,22 @@ io.on("connection", (socket) => {
|
||||
return
|
||||
}
|
||||
|
||||
socket.emit("game:reset", "Game not found")
|
||||
ensureGame(gameId).then((restored) => {
|
||||
if (restored) {
|
||||
restored.reconnect(socket)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
socket.emit("game:reset", "Game not found")
|
||||
})
|
||||
})
|
||||
|
||||
socket.on("manager:reconnect", ({ gameId }) => {
|
||||
const game = registry.getManagerGame(gameId, socket.handshake.auth.clientId)
|
||||
const game = registry.getManagerGame(
|
||||
gameId,
|
||||
socket.handshake.auth.clientId
|
||||
)
|
||||
|
||||
if (game) {
|
||||
game.reconnect(socket)
|
||||
@@ -55,7 +85,15 @@ io.on("connection", (socket) => {
|
||||
return
|
||||
}
|
||||
|
||||
socket.emit("game:reset", "Game expired")
|
||||
ensureGame(gameId).then((restored) => {
|
||||
if (restored) {
|
||||
restored.reconnect(socket)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
socket.emit("game:reset", "Game expired")
|
||||
})
|
||||
})
|
||||
|
||||
socket.on("manager:auth", (password) => {
|
||||
@@ -111,6 +149,27 @@ io.on("connection", (socket) => {
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("manager:deleteQuizz", ({ id }) => {
|
||||
if (!id) {
|
||||
socket.emit("manager:errorMessage", "Invalid quizz id")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const deleted = Config.deleteQuizz(id)
|
||||
if (!deleted) {
|
||||
socket.emit("manager:errorMessage", "Quizz not found")
|
||||
return
|
||||
}
|
||||
|
||||
socket.emit("manager:quizzDeleted", id)
|
||||
socket.emit("manager:quizzList", Config.quizz())
|
||||
} catch (error) {
|
||||
console.error("Failed to delete quizz", error)
|
||||
socket.emit("manager:errorMessage", "Failed to delete quizz")
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("game:create", (quizzId) => {
|
||||
const quizzList = Config.quizz()
|
||||
const quizz = quizzList.find((q) => q.id === quizzId)
|
||||
@@ -159,7 +218,7 @@ io.on("connection", (socket) => {
|
||||
|
||||
socket.on("player:selectedAnswer", ({ gameId, data }) =>
|
||||
withGame(gameId, socket, (game) =>
|
||||
game.selectAnswer(socket, data.answerKey)
|
||||
game.selectAnswer(socket, data.answerKeys)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -167,10 +226,26 @@ io.on("connection", (socket) => {
|
||||
withGame(gameId, socket, (game) => game.abortRound(socket))
|
||||
)
|
||||
|
||||
socket.on("manager:pauseCooldown", ({ gameId }) =>
|
||||
withGame(gameId, socket, (game) => game.pauseCooldown(socket))
|
||||
)
|
||||
|
||||
socket.on("manager:resumeCooldown", ({ gameId }) =>
|
||||
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))
|
||||
)
|
||||
|
||||
socket.on("manager:skipQuestionIntro", ({ gameId }) =>
|
||||
withGame(gameId, socket, (game) => game.skipQuestionIntro(socket))
|
||||
)
|
||||
|
||||
socket.on("manager:showLeaderboard", ({ gameId }) =>
|
||||
withGame(gameId, socket, (game) => game.showLeaderboard())
|
||||
)
|
||||
@@ -206,19 +281,9 @@ io.on("connection", (socket) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!game.started) {
|
||||
game.players = game.players.filter((p) => p.id !== socket.id)
|
||||
|
||||
io.to(game.manager.id).emit("manager:removePlayer", player.id)
|
||||
io.to(game.gameId).emit("game:totalPlayers", game.players.length)
|
||||
|
||||
console.log(`Removed player ${player.username} from game ${game.gameId}`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
player.connected = false
|
||||
io.to(game.gameId).emit("game:totalPlayers", game.players.length)
|
||||
io.to(game.manager.id).emit("manager:players", game.players)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ class Config {
|
||||
type: "audio",
|
||||
url: "https://upload.wikimedia.org/wikipedia/commons/transcoded/4/4c/Beethoven_Moonlight_1st_movement.ogg/Beethoven_Moonlight_1st_movement.ogg.mp3",
|
||||
},
|
||||
solution: 1,
|
||||
solution: [1],
|
||||
cooldown: 5,
|
||||
time: 25,
|
||||
},
|
||||
@@ -120,7 +120,7 @@ class Config {
|
||||
type: "video",
|
||||
url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4",
|
||||
},
|
||||
solution: 2,
|
||||
solution: [2],
|
||||
cooldown: 5,
|
||||
time: 40,
|
||||
},
|
||||
@@ -214,6 +214,18 @@ class Config {
|
||||
return this.getQuizz(finalId)
|
||||
}
|
||||
|
||||
static deleteQuizz(id: string) {
|
||||
this.ensureBaseFolders()
|
||||
const filePath = getPath(`quizz/${id}.json`)
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
fs.unlinkSync(filePath)
|
||||
return true
|
||||
}
|
||||
|
||||
static getMediaPath(fileName: string = "") {
|
||||
this.ensureBaseFolders()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Answer, Player, Quizz } from "@rahoot/common/types/game"
|
||||
import { Server, Socket } from "@rahoot/common/types/game/socket"
|
||||
import { Status, STATUS, StatusDataMap } from "@rahoot/common/types/game/status"
|
||||
import Registry from "@rahoot/socket/services/registry"
|
||||
import { saveSnapshot, loadSnapshot, deleteSnapshot, GameSnapshot } from "@rahoot/socket/services/persistence"
|
||||
import { createInviteCode, timeToPoint } from "@rahoot/socket/utils/game"
|
||||
import sleep from "@rahoot/socket/utils/sleep"
|
||||
import { v4 as uuid } from "uuid"
|
||||
@@ -40,7 +41,10 @@ class Game {
|
||||
|
||||
cooldown: {
|
||||
active: boolean
|
||||
ms: number
|
||||
paused: boolean
|
||||
remaining: number
|
||||
timer: NodeJS.Timeout | null
|
||||
resolve: (() => void) | null
|
||||
}
|
||||
|
||||
constructor(io: Server, socket: Socket, quizz: Quizz) {
|
||||
@@ -52,8 +56,8 @@ class Game {
|
||||
this.gameId = uuid()
|
||||
this.manager = {
|
||||
id: "",
|
||||
clientId: "",
|
||||
connected: false,
|
||||
clientId: socket.handshake.auth.clientId,
|
||||
connected: true,
|
||||
}
|
||||
this.inviteCode = ""
|
||||
this.started = false
|
||||
@@ -75,16 +79,16 @@ class Game {
|
||||
|
||||
this.cooldown = {
|
||||
active: false,
|
||||
ms: 0,
|
||||
paused: false,
|
||||
remaining: 0,
|
||||
timer: null,
|
||||
resolve: null,
|
||||
}
|
||||
|
||||
const roomInvite = createInviteCode()
|
||||
this.inviteCode = roomInvite
|
||||
this.manager = {
|
||||
id: socket.id,
|
||||
clientId: socket.handshake.auth.clientId,
|
||||
connected: true,
|
||||
}
|
||||
this.manager.id = socket.id
|
||||
|
||||
this.quizz = quizz
|
||||
|
||||
socket.join(this.gameId)
|
||||
@@ -96,12 +100,70 @@ class Game {
|
||||
console.log(
|
||||
`New game created: ${roomInvite} subject: ${this.quizz.subject}`
|
||||
)
|
||||
this.persist()
|
||||
}
|
||||
|
||||
static async fromSnapshot(io: Server, snapshot: GameSnapshot) {
|
||||
const game = Object.create(Game.prototype) as Game
|
||||
game.io = io
|
||||
game.gameId = snapshot.gameId
|
||||
game.manager = {
|
||||
id: "",
|
||||
clientId: snapshot.manager?.clientId || "",
|
||||
connected: false,
|
||||
}
|
||||
game.inviteCode = snapshot.inviteCode
|
||||
game.started = snapshot.started
|
||||
game.lastBroadcastStatus = snapshot.lastBroadcastStatus || null
|
||||
game.managerStatus = snapshot.managerStatus || null
|
||||
game.playerStatus = new Map()
|
||||
game.leaderboard = snapshot.leaderboard || []
|
||||
game.tempOldLeaderboard = snapshot.tempOldLeaderboard || null
|
||||
game.quizz = snapshot.quizz
|
||||
game.players = (snapshot.players || []).map((p: Player) => ({
|
||||
...p,
|
||||
id: "",
|
||||
connected: false,
|
||||
}))
|
||||
const round = snapshot.round || {
|
||||
playersAnswers: [],
|
||||
currentQuestion: 0,
|
||||
startTime: 0,
|
||||
}
|
||||
const migratedAnswers = Array.isArray(round.playersAnswers)
|
||||
? round.playersAnswers.map((a: any) => ({
|
||||
...a,
|
||||
answerIds: Array.isArray(a.answerIds)
|
||||
? a.answerIds
|
||||
: typeof a.answerId === "number"
|
||||
? [a.answerId]
|
||||
: [],
|
||||
}))
|
||||
: []
|
||||
game.round = {
|
||||
...round,
|
||||
playersAnswers: migratedAnswers,
|
||||
}
|
||||
game.cooldown = {
|
||||
active: snapshot.cooldown?.active || false,
|
||||
paused: snapshot.cooldown?.paused || false,
|
||||
remaining: snapshot.cooldown?.remaining || 0,
|
||||
timer: null,
|
||||
resolve: null,
|
||||
}
|
||||
|
||||
if (game.cooldown.active && game.cooldown.remaining > 0 && !game.cooldown.paused) {
|
||||
game.startCooldown(game.cooldown.remaining)
|
||||
}
|
||||
|
||||
return game
|
||||
}
|
||||
|
||||
broadcastStatus<T extends Status>(status: T, data: StatusDataMap[T]) {
|
||||
const statusData = { name: status, data }
|
||||
this.lastBroadcastStatus = statusData
|
||||
this.io.to(this.gameId).emit("game:status", statusData)
|
||||
this.persist()
|
||||
}
|
||||
|
||||
sendStatus<T extends Status>(
|
||||
@@ -118,16 +180,65 @@ class Game {
|
||||
}
|
||||
|
||||
this.io.to(target).emit("game:status", statusData)
|
||||
this.persist()
|
||||
}
|
||||
|
||||
toSnapshot(): GameSnapshot {
|
||||
return {
|
||||
gameId: this.gameId,
|
||||
inviteCode: this.inviteCode,
|
||||
started: this.started,
|
||||
manager: {
|
||||
clientId: this.manager.clientId,
|
||||
},
|
||||
lastBroadcastStatus: this.lastBroadcastStatus,
|
||||
managerStatus: this.managerStatus,
|
||||
leaderboard: this.leaderboard,
|
||||
tempOldLeaderboard: this.tempOldLeaderboard,
|
||||
quizz: this.quizz,
|
||||
players: this.players.map((p) => ({
|
||||
...p,
|
||||
id: undefined,
|
||||
connected: false,
|
||||
})),
|
||||
round: this.round,
|
||||
cooldown: {
|
||||
active: this.cooldown.active,
|
||||
paused: this.cooldown.paused,
|
||||
remaining: this.cooldown.remaining,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async persist() {
|
||||
try {
|
||||
await saveSnapshot(this.gameId, this.toSnapshot())
|
||||
} catch (error) {
|
||||
console.error("Failed to persist game snapshot", error)
|
||||
}
|
||||
}
|
||||
|
||||
async clearPersisted() {
|
||||
try {
|
||||
await deleteSnapshot(this.gameId)
|
||||
} catch (error) {
|
||||
console.error("Failed to delete game snapshot", error)
|
||||
}
|
||||
}
|
||||
|
||||
join(socket: Socket, username: string) {
|
||||
const isAlreadyConnected = this.players.find(
|
||||
const existing = this.players.find(
|
||||
(p) => p.clientId === socket.handshake.auth.clientId
|
||||
)
|
||||
|
||||
if (isAlreadyConnected) {
|
||||
socket.emit("game:errorMessage", "Player already connected")
|
||||
|
||||
if (existing) {
|
||||
// Reconnect existing player (even before game start)
|
||||
existing.id = socket.id
|
||||
existing.connected = true
|
||||
if (username) existing.username = username
|
||||
socket.join(this.gameId)
|
||||
this.io.to(this.manager.id).emit("manager:players", this.players)
|
||||
socket.emit("game:successJoin", this.gameId)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -144,6 +255,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)
|
||||
@@ -168,6 +280,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)
|
||||
}
|
||||
@@ -181,6 +294,7 @@ class Game {
|
||||
} else {
|
||||
this.reconnectPlayer(socket)
|
||||
}
|
||||
this.io.to(this.manager.id).emit("manager:players", this.players)
|
||||
}
|
||||
|
||||
private reconnectManager(socket: Socket) {
|
||||
@@ -246,6 +360,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,
|
||||
@@ -271,26 +386,97 @@ class Game {
|
||||
}
|
||||
|
||||
this.cooldown.active = true
|
||||
let count = seconds - 1
|
||||
this.cooldown.paused = false
|
||||
this.cooldown.remaining = seconds
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const cooldownTimeout = setInterval(() => {
|
||||
if (!this.cooldown.active || count <= 0) {
|
||||
this.cooldown.active = false
|
||||
clearInterval(cooldownTimeout)
|
||||
resolve()
|
||||
this.cooldown.resolve = resolve
|
||||
|
||||
const tick = () => {
|
||||
if (!this.cooldown.active) {
|
||||
this.finishCooldown()
|
||||
return
|
||||
}
|
||||
|
||||
this.io.to(this.gameId).emit("game:cooldown", count)
|
||||
count -= 1
|
||||
}, 1000)
|
||||
if (this.cooldown.paused) {
|
||||
return
|
||||
}
|
||||
|
||||
this.cooldown.remaining -= 1
|
||||
|
||||
if (this.cooldown.remaining <= 0) {
|
||||
this.finishCooldown()
|
||||
return
|
||||
}
|
||||
|
||||
this.io.to(this.gameId).emit("game:cooldown", this.cooldown.remaining)
|
||||
this.persist()
|
||||
}
|
||||
|
||||
// initial emit
|
||||
this.io.to(this.gameId).emit("game:cooldown", this.cooldown.remaining)
|
||||
this.persist()
|
||||
|
||||
this.cooldown.timer = setInterval(tick, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
abortCooldown() {
|
||||
this.cooldown.active &&= false
|
||||
if (!this.cooldown.active) {
|
||||
return
|
||||
}
|
||||
|
||||
this.cooldown.active = false
|
||||
this.cooldown.paused = false
|
||||
this.io.to(this.gameId).emit("game:cooldownPause", false)
|
||||
this.persist()
|
||||
this.finishCooldown()
|
||||
}
|
||||
|
||||
finishCooldown() {
|
||||
if (this.cooldown.timer) {
|
||||
clearInterval(this.cooldown.timer)
|
||||
}
|
||||
this.cooldown.timer = null
|
||||
this.cooldown.active = false
|
||||
this.cooldown.paused = false
|
||||
this.cooldown.remaining = 0
|
||||
if (this.cooldown.resolve) {
|
||||
this.cooldown.resolve()
|
||||
}
|
||||
this.cooldown.resolve = null
|
||||
}
|
||||
|
||||
pauseCooldown(socket: Socket) {
|
||||
if (this.manager.id !== socket.id || !this.cooldown.active || this.cooldown.paused) {
|
||||
return
|
||||
}
|
||||
|
||||
this.cooldown.paused = true
|
||||
this.io.to(this.gameId).emit("game:cooldownPause", true)
|
||||
this.persist()
|
||||
}
|
||||
|
||||
resumeCooldown(socket: Socket) {
|
||||
if (this.manager.id !== socket.id || !this.cooldown.active || !this.cooldown.paused) {
|
||||
return
|
||||
}
|
||||
|
||||
this.cooldown.paused = false
|
||||
this.io.to(this.gameId).emit("game:cooldownPause", false)
|
||||
this.persist()
|
||||
}
|
||||
|
||||
skipQuestionIntro(socket: Socket) {
|
||||
if (this.manager.id !== socket.id) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.started) {
|
||||
return
|
||||
}
|
||||
|
||||
this.abortCooldown()
|
||||
}
|
||||
|
||||
async start(socket: Socket) {
|
||||
@@ -315,6 +501,7 @@ class Game {
|
||||
await this.startCooldown(3)
|
||||
|
||||
this.newRound()
|
||||
this.persist()
|
||||
}
|
||||
|
||||
async newRound() {
|
||||
@@ -346,11 +533,11 @@ class Game {
|
||||
this.broadcastStatus(STATUS.SHOW_QUESTION, {
|
||||
question: question.question,
|
||||
image: question.image,
|
||||
media: question.media,
|
||||
media: question.media,
|
||||
cooldown: question.cooldown,
|
||||
})
|
||||
|
||||
await sleep(question.cooldown)
|
||||
await this.startCooldown(question.cooldown)
|
||||
|
||||
if (!this.started) {
|
||||
return
|
||||
@@ -362,9 +549,11 @@ class Game {
|
||||
question: question.question,
|
||||
answers: question.answers,
|
||||
image: question.image,
|
||||
media: question.media,
|
||||
media: question.media,
|
||||
time: question.time,
|
||||
totalPlayer: this.players.length,
|
||||
allowsMultiple:
|
||||
Array.isArray(question.solution) && question.solution.length > 1,
|
||||
})
|
||||
|
||||
await this.startCooldown(question.time)
|
||||
@@ -374,6 +563,7 @@ class Game {
|
||||
}
|
||||
|
||||
this.showResults(question)
|
||||
this.persist()
|
||||
}
|
||||
|
||||
showResults(question: any) {
|
||||
@@ -383,9 +573,10 @@ class Game {
|
||||
: this.leaderboard.map((p) => ({ ...p }))
|
||||
|
||||
const totalType = this.round.playersAnswers.reduce(
|
||||
(acc: Record<number, number>, { answerId }) => {
|
||||
acc[answerId] = (acc[answerId] || 0) + 1
|
||||
|
||||
(acc: Record<number, number>, { answerIds }) => {
|
||||
answerIds.forEach((id) => {
|
||||
acc[id] = (acc[id] || 0) + 1
|
||||
})
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
@@ -397,8 +588,16 @@ class Game {
|
||||
(a) => a.playerId === player.id
|
||||
)
|
||||
|
||||
const correctAnswers = Array.isArray(question.solution)
|
||||
? Array.from(new Set(question.solution))
|
||||
: [question.solution]
|
||||
|
||||
const isCorrect = playerAnswer
|
||||
? playerAnswer.answerId === question.solution
|
||||
? (() => {
|
||||
const chosen = Array.from(new Set(playerAnswer.answerIds))
|
||||
if (chosen.length !== correctAnswers.length) return false
|
||||
return correctAnswers.every((id: number) => chosen.includes(id))
|
||||
})()
|
||||
: false
|
||||
|
||||
const points =
|
||||
@@ -432,15 +631,16 @@ class Game {
|
||||
correct: question.solution,
|
||||
answers: question.answers,
|
||||
image: question.image,
|
||||
media: question.media,
|
||||
media: question.media,
|
||||
})
|
||||
|
||||
this.leaderboard = sortedPlayers
|
||||
this.tempOldLeaderboard = oldLeaderboard
|
||||
|
||||
this.round.playersAnswers = []
|
||||
this.persist()
|
||||
}
|
||||
selectAnswer(socket: Socket, answerId: number) {
|
||||
selectAnswer(socket: Socket, answerIds: number[]) {
|
||||
const player = this.players.find((player) => player.id === socket.id)
|
||||
const question = this.quizz.questions[this.round.currentQuestion]
|
||||
|
||||
@@ -452,9 +652,17 @@ class Game {
|
||||
return
|
||||
}
|
||||
|
||||
const uniqueAnswers = Array.from(new Set(answerIds)).filter(
|
||||
(id) => !Number.isNaN(id)
|
||||
)
|
||||
|
||||
if (uniqueAnswers.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.round.playersAnswers.push({
|
||||
playerId: player.id,
|
||||
answerId,
|
||||
answerIds: uniqueAnswers,
|
||||
points: timeToPoint(this.round.startTime, question.time),
|
||||
})
|
||||
|
||||
@@ -471,6 +679,7 @@ class Game {
|
||||
if (this.round.playersAnswers.length === this.players.length) {
|
||||
this.abortCooldown()
|
||||
}
|
||||
this.persist()
|
||||
}
|
||||
|
||||
nextRound(socket: Socket) {
|
||||
@@ -513,6 +722,7 @@ class Game {
|
||||
subject: this.quizz.subject,
|
||||
top: this.leaderboard.slice(0, 3),
|
||||
})
|
||||
this.clearPersisted()
|
||||
|
||||
return
|
||||
}
|
||||
@@ -527,6 +737,17 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
36
packages/socket/src/services/persistence.ts
Normal file
36
packages/socket/src/services/persistence.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createClient } from "redis"
|
||||
|
||||
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379"
|
||||
|
||||
const redis =
|
||||
createClient({ url: redisUrl })
|
||||
.on("error", (err) => console.error("Redis Client Error", err))
|
||||
|
||||
export type GameSnapshot = Record<string, any>
|
||||
|
||||
export const connectRedis = async () => {
|
||||
if (!redis.isOpen) {
|
||||
await redis.connect()
|
||||
}
|
||||
}
|
||||
|
||||
export const saveSnapshot = async (gameId: string, snapshot: GameSnapshot) => {
|
||||
if (!gameId) return
|
||||
await connectRedis()
|
||||
await redis.set(`game:${gameId}`, JSON.stringify(snapshot), {
|
||||
EX: 60 * 60 * 6, // 6 hours
|
||||
})
|
||||
}
|
||||
|
||||
export const loadSnapshot = async (gameId: string): Promise<GameSnapshot | null> => {
|
||||
if (!gameId) return null
|
||||
await connectRedis()
|
||||
const raw = await redis.get(`game:${gameId}`)
|
||||
return raw ? (JSON.parse(raw) as GameSnapshot) : null
|
||||
}
|
||||
|
||||
export const deleteSnapshot = async (gameId: string) => {
|
||||
if (!gameId) return
|
||||
await connectRedis()
|
||||
await redis.del(`game:${gameId}`)
|
||||
}
|
||||
@@ -86,6 +86,9 @@ class Registry {
|
||||
}
|
||||
|
||||
removeGame(gameId: string): boolean {
|
||||
const game = this.games.find((g) => g.gameId === gameId)
|
||||
void game?.clearPersisted?.()
|
||||
|
||||
const initialLength = this.games.length
|
||||
this.games = this.games.filter((g) => g.gameId !== gameId)
|
||||
this.emptyGames = this.emptyGames.filter((g) => g.game.gameId !== gameId)
|
||||
@@ -125,6 +128,7 @@ class Registry {
|
||||
|
||||
const removed = this.emptyGames.filter((g) => !stillEmpty.includes(g))
|
||||
const removedGameIds = removed.map((r) => r.game.gameId)
|
||||
removed.forEach((entry) => void entry.game.clearPersisted?.())
|
||||
|
||||
this.games = this.games.filter((g) => !removedGameIds.includes(g.gameId))
|
||||
this.emptyGames = stillEmpty
|
||||
|
||||
@@ -4,6 +4,7 @@ import { QuizzWithId } from "@rahoot/common/types/game"
|
||||
import { STATUS } from "@rahoot/common/types/game/status"
|
||||
import ManagerPassword from "@rahoot/web/components/game/create/ManagerPassword"
|
||||
import QuizEditor from "@rahoot/web/components/game/create/QuizEditor"
|
||||
import MediaLibrary from "@rahoot/web/components/game/create/MediaLibrary"
|
||||
import SelectQuizz from "@rahoot/web/components/game/create/SelectQuizz"
|
||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||
import { useManagerStore } from "@rahoot/web/stores/manager"
|
||||
@@ -18,6 +19,7 @@ const Manager = () => {
|
||||
const [isAuth, setIsAuth] = useState(false)
|
||||
const [quizzList, setQuizzList] = useState<QuizzWithId[]>([])
|
||||
const [showEditor, setShowEditor] = useState(false)
|
||||
const [showMedia, setShowMedia] = useState(false)
|
||||
|
||||
useEvent("manager:quizzList", (quizzList) => {
|
||||
setIsAuth(true)
|
||||
@@ -51,11 +53,28 @@ const Manager = () => {
|
||||
)
|
||||
}
|
||||
|
||||
if (showMedia) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowMedia(false)}
|
||||
className="rounded-md bg-gray-700 px-3 py-2 text-white"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<MediaLibrary />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectQuizz
|
||||
quizzList={quizzList}
|
||||
onSelect={handleCreate}
|
||||
onManage={() => setShowEditor(true)}
|
||||
onMedia={() => setShowMedia(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import Username from "@rahoot/web/components/game/join/Username"
|
||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||
import { usePlayerStore } from "@rahoot/web/stores/player"
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import toast from "react-hot-toast"
|
||||
|
||||
const Home = () => {
|
||||
const { isConnected, connect } = useSocket()
|
||||
const { isConnected, connect, socket } = useSocket()
|
||||
const { player } = usePlayerStore()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected) {
|
||||
@@ -17,6 +19,19 @@ const Home = () => {
|
||||
}
|
||||
}, [connect, isConnected])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected) return
|
||||
try {
|
||||
const storedGameId = localStorage.getItem("last_game_id")
|
||||
if (storedGameId) {
|
||||
socket?.emit("player:reconnect", { gameId: storedGameId })
|
||||
router.replace(`/game/${storedGameId}`)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [isConnected, socket, router])
|
||||
|
||||
useEvent("game:errorMessage", (message) => {
|
||||
toast.error(message)
|
||||
})
|
||||
|
||||
@@ -13,13 +13,15 @@ import { usePlayerStore } from "@rahoot/web/stores/player"
|
||||
import { useQuestionStore } from "@rahoot/web/stores/question"
|
||||
import { GAME_STATE_COMPONENTS } from "@rahoot/web/utils/constants"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import toast from "react-hot-toast"
|
||||
|
||||
const Game = () => {
|
||||
const router = useRouter()
|
||||
const { socket } = useSocket()
|
||||
const { gameId: gameIdParam }: { gameId?: string } = useParams()
|
||||
const { status, setPlayer, setGameId, setStatus, reset } = usePlayerStore()
|
||||
const { status, player, setPlayer, setGameId, setStatus, reset } =
|
||||
usePlayerStore()
|
||||
const { setQuestionStates } = useQuestionStore()
|
||||
|
||||
useEvent("connect", () => {
|
||||
@@ -35,6 +37,12 @@ const Game = () => {
|
||||
setStatus(status.name, status.data)
|
||||
setPlayer(player)
|
||||
setQuestionStates(currentQuestion)
|
||||
try {
|
||||
localStorage.setItem("last_game_id", gameId)
|
||||
if (player?.username) {
|
||||
localStorage.setItem("last_username", player.username)
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -48,9 +56,32 @@ const Game = () => {
|
||||
router.replace("/")
|
||||
reset()
|
||||
setQuestionStates(null)
|
||||
try {
|
||||
localStorage.removeItem("last_game_id")
|
||||
localStorage.removeItem("last_username")
|
||||
localStorage.removeItem("last_points")
|
||||
} catch {}
|
||||
toast.error(message)
|
||||
})
|
||||
|
||||
// Hydrate username/points for footer immediately after refresh
|
||||
useEffect(() => {
|
||||
if (player?.username) return
|
||||
try {
|
||||
const name = localStorage.getItem("last_username")
|
||||
const ptsRaw = localStorage.getItem("last_points")
|
||||
const pts = ptsRaw ? Number(ptsRaw) : undefined
|
||||
if (name || typeof pts === "number") {
|
||||
setPlayer({
|
||||
username: name || undefined,
|
||||
points: pts,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [player?.username, setPlayer])
|
||||
|
||||
if (!gameIdParam) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useQuestionStore } from "@rahoot/web/stores/question"
|
||||
import { GAME_STATE_COMPONENTS_MANAGER } from "@rahoot/web/utils/constants"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import toast from "react-hot-toast"
|
||||
import { useState } from "react"
|
||||
|
||||
const ManagerGame = () => {
|
||||
const router = useRouter()
|
||||
@@ -24,6 +25,8 @@ const ManagerGame = () => {
|
||||
const { gameId, status, setGameId, setStatus, setPlayers, reset } =
|
||||
useManagerStore()
|
||||
const { setQuestionStates } = useQuestionStore()
|
||||
const [cooldownPaused, setCooldownPaused] = useState(false)
|
||||
const { players } = useManagerStore()
|
||||
|
||||
useEvent("game:status", ({ name, data }) => {
|
||||
if (name in GAME_STATE_COMPONENTS_MANAGER) {
|
||||
@@ -54,6 +57,22 @@ 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)
|
||||
})
|
||||
|
||||
const handleSkip = () => {
|
||||
if (!gameId) {
|
||||
return
|
||||
@@ -65,6 +84,11 @@ const ManagerGame = () => {
|
||||
|
||||
break
|
||||
|
||||
case STATUS.SHOW_QUESTION:
|
||||
socket?.emit("manager:skipQuestionIntro", { gameId })
|
||||
|
||||
break
|
||||
|
||||
case STATUS.SELECT_ANSWER:
|
||||
socket?.emit("manager:abortQuiz", { gameId })
|
||||
|
||||
@@ -82,6 +106,20 @@ const ManagerGame = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePauseToggle = () => {
|
||||
if (!gameId) return
|
||||
if (cooldownPaused) {
|
||||
socket?.emit("manager:resumeCooldown", { gameId })
|
||||
} else {
|
||||
socket?.emit("manager:pauseCooldown", { gameId })
|
||||
}
|
||||
}
|
||||
|
||||
const handleEndGame = () => {
|
||||
if (!gameId) return
|
||||
socket?.emit("manager:endGame", { gameId })
|
||||
}
|
||||
|
||||
let component = null
|
||||
|
||||
switch (status?.name) {
|
||||
@@ -127,7 +165,18 @@ const ManagerGame = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<GameWrapper statusName={status?.name} onNext={handleSkip} manager>
|
||||
<GameWrapper
|
||||
statusName={status?.name}
|
||||
onNext={handleSkip}
|
||||
onPause={handlePauseToggle}
|
||||
paused={cooldownPaused}
|
||||
showPause={
|
||||
status?.name === STATUS.SHOW_QUESTION || status?.name === STATUS.SELECT_ANSWER
|
||||
}
|
||||
onEnd={handleEndGame}
|
||||
players={players}
|
||||
manager
|
||||
>
|
||||
{component}
|
||||
</GameWrapper>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import Config from "@rahoot/socket/services/config"
|
||||
import { mimeForStoredFile } from "@rahoot/web/server/media"
|
||||
import fs from "fs"
|
||||
import { promises as fsp } from "fs"
|
||||
import { Readable } from "node:stream"
|
||||
import path from "path"
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
@@ -42,14 +43,20 @@ export async function GET(
|
||||
const start = Number(rawStart)
|
||||
const end = rawEnd ? Number(rawEnd) : fileSize - 1
|
||||
|
||||
if (Number.isNaN(start) || Number.isNaN(end) || start > end) {
|
||||
if (
|
||||
Number.isNaN(start) ||
|
||||
Number.isNaN(end) ||
|
||||
start < 0 ||
|
||||
end >= fileSize ||
|
||||
start > end
|
||||
) {
|
||||
return new NextResponse(null, { status: 416 })
|
||||
}
|
||||
|
||||
const chunkSize = end - start + 1
|
||||
const stream = fs.createReadStream(filePath, { start, end })
|
||||
|
||||
return new NextResponse(stream as any, {
|
||||
return new NextResponse(Readable.toWeb(stream) as any, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
|
||||
@@ -61,9 +68,9 @@ export async function GET(
|
||||
})
|
||||
}
|
||||
|
||||
const buffer = await fsp.readFile(filePath)
|
||||
const stream = fs.createReadStream(filePath)
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
return new NextResponse(Readable.toWeb(stream) as any, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mime,
|
||||
|
||||
@@ -4,17 +4,20 @@ import { ButtonHTMLAttributes, ElementType, PropsWithChildren } from "react"
|
||||
type Props = PropsWithChildren &
|
||||
ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
icon: ElementType
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
const AnswerButton = ({
|
||||
className,
|
||||
icon: Icon,
|
||||
children,
|
||||
selected = false,
|
||||
...otherProps
|
||||
}: Props) => (
|
||||
<button
|
||||
className={clsx(
|
||||
"shadow-inset flex items-center gap-3 rounded px-4 py-6 text-left",
|
||||
"shadow-inset flex items-center gap-3 rounded px-4 py-6 text-left transition-all",
|
||||
selected && "ring-4 ring-white/80 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...otherProps}
|
||||
|
||||
@@ -15,10 +15,25 @@ import { PropsWithChildren, useEffect, useState } from "react"
|
||||
type Props = PropsWithChildren & {
|
||||
statusName: Status | undefined
|
||||
onNext?: () => void
|
||||
onPause?: () => void
|
||||
paused?: boolean
|
||||
showPause?: boolean
|
||||
onEnd?: () => void
|
||||
players?: { id: string; username: string; connected: boolean }[]
|
||||
manager?: boolean
|
||||
}
|
||||
|
||||
const GameWrapper = ({ children, statusName, onNext, manager }: Props) => {
|
||||
const GameWrapper = ({
|
||||
children,
|
||||
statusName,
|
||||
onNext,
|
||||
onPause,
|
||||
paused,
|
||||
showPause,
|
||||
onEnd,
|
||||
players,
|
||||
manager,
|
||||
}: Props) => {
|
||||
const { isConnected } = useSocket()
|
||||
const { player } = usePlayerStore()
|
||||
const { questionStates, setQuestionStates } = useQuestionStore()
|
||||
@@ -75,8 +90,48 @@ const GameWrapper = ({ children, statusName, onNext, manager }: Props) => {
|
||||
{next}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{manager && showPause && (
|
||||
<Button
|
||||
className={clsx("self-end bg-white px-4 text-black!", {
|
||||
"pointer-events-none": isDisabled,
|
||||
})}
|
||||
onClick={onPause}
|
||||
>
|
||||
{paused ? "Resume" : "Pause"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{manager && onEnd && (
|
||||
<Button className="self-end bg-red-600 px-4" onClick={onEnd}>
|
||||
End game
|
||||
</Button>
|
||||
)}
|
||||
</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}
|
||||
|
||||
{!manager && (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { QuestionMedia as QuestionMediaType } from "@rahoot/common/types/game"
|
||||
import clsx from "clsx"
|
||||
import { useState } from "react"
|
||||
|
||||
type Props = {
|
||||
media?: QuestionMediaType
|
||||
@@ -10,22 +11,39 @@ type Props = {
|
||||
}
|
||||
|
||||
const QuestionMedia = ({ media, alt, onPlayChange }: Props) => {
|
||||
const [zoomed, setZoomed] = useState(false)
|
||||
|
||||
if (!media) {
|
||||
return null
|
||||
}
|
||||
|
||||
const containerClass = "mx-auto flex w-full max-w-3xl justify-center"
|
||||
const containerClass = "mx-auto flex w-full max-w-5xl justify-center"
|
||||
|
||||
switch (media.type) {
|
||||
case "image":
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
<>
|
||||
<div className={containerClass}>
|
||||
<img
|
||||
alt={alt}
|
||||
src={media.url}
|
||||
className="m-4 h-full max-h-[400px] min-h-[200px] w-auto max-w-full rounded-md object-contain shadow-lg"
|
||||
className="m-4 h-full max-h-[400px] min-h-[200px] w-auto max-w-full cursor-zoom-in rounded-md object-contain shadow-lg"
|
||||
onClick={() => setZoomed(true)}
|
||||
/>
|
||||
</div>
|
||||
{zoomed && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
|
||||
onClick={() => setZoomed(false)}
|
||||
>
|
||||
<img
|
||||
src={media.url}
|
||||
alt={alt}
|
||||
className="max-h-[90vh] max-w-[90vw] rounded-md shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
case "audio":
|
||||
@@ -33,6 +51,7 @@ const QuestionMedia = ({ media, alt, onPlayChange }: Props) => {
|
||||
<div className={clsx(containerClass, "px-4")}>
|
||||
<audio
|
||||
controls
|
||||
crossOrigin="anonymous"
|
||||
src={media.url}
|
||||
className="mt-4 w-full rounded-md bg-black/40 p-2 shadow-lg"
|
||||
preload="none"
|
||||
@@ -48,8 +67,10 @@ const QuestionMedia = ({ media, alt, onPlayChange }: Props) => {
|
||||
<div className={containerClass}>
|
||||
<video
|
||||
controls
|
||||
crossOrigin="anonymous"
|
||||
playsInline
|
||||
src={media.url}
|
||||
className="m-4 w-full max-w-3xl rounded-md shadow-lg"
|
||||
className="m-4 w-full max-w-5xl rounded-md shadow-lg"
|
||||
preload="metadata"
|
||||
onPlay={() => onPlayChange?.(true)}
|
||||
onPause={() => onPlayChange?.(false)}
|
||||
|
||||
147
packages/web/src/components/game/create/MediaLibrary.tsx
Normal file
147
packages/web/src/components/game/create/MediaLibrary.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client"
|
||||
|
||||
import Button from "@rahoot/web/components/Button"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
type MediaItem = {
|
||||
fileName: string
|
||||
url: string
|
||||
size: number
|
||||
mime: string
|
||||
type: string
|
||||
usedBy: {
|
||||
quizzId: string
|
||||
subject: string
|
||||
questionIndex: number
|
||||
question: string
|
||||
}[]
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (!bytes) return "0 B"
|
||||
const units = ["B", "KB", "MB", "GB"]
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
const value = bytes / 1024 ** i
|
||||
return `${value.toFixed(value >= 10 || value % 1 === 0 ? 0 : 1)} ${units[i]}`
|
||||
}
|
||||
|
||||
const MediaLibrary = () => {
|
||||
const [items, setItems] = useState<MediaItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [deleting, setDeleting] = useState<Record<string, boolean>>({})
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch("/api/media", { cache: "no-store" })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Failed to load media")
|
||||
setItems(data.media || [])
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (fileName: string) => {
|
||||
setDeleting((prev) => ({ ...prev, [fileName]: true }))
|
||||
try {
|
||||
const res = await fetch(`/api/media/${encodeURIComponent(fileName)}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Failed to delete file")
|
||||
load()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
alert(error instanceof Error ? error.message : "Failed to delete")
|
||||
} finally {
|
||||
setDeleting((prev) => ({ ...prev, [fileName]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800">Media library</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Uploaded files with their usage. Delete is enabled only when unused.
|
||||
</p>
|
||||
</div>
|
||||
<Button className="bg-gray-700" onClick={load} disabled={loading}>
|
||||
{loading ? "Refreshing..." : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase text-gray-500">
|
||||
<th className="p-2">File</th>
|
||||
<th className="p-2">Type</th>
|
||||
<th className="p-2">Size</th>
|
||||
<th className="p-2">Used by</th>
|
||||
<th className="p-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.fileName} className="border-b border-gray-100">
|
||||
<td className="p-2 font-semibold text-gray-800">
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-600 underline"
|
||||
>
|
||||
{item.fileName}
|
||||
</a>
|
||||
</td>
|
||||
<td className="p-2">{item.type}</td>
|
||||
<td className="p-2 text-gray-600">{formatBytes(item.size)}</td>
|
||||
<td className="p-2">
|
||||
{item.usedBy.length === 0 ? (
|
||||
<span className="text-green-700">Unused</span>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{item.usedBy.map((u, idx) => (
|
||||
<div key={idx} className="text-gray-700">
|
||||
<span className="font-semibold">{u.subject || u.quizzId}</span>
|
||||
{` – Q${u.questionIndex + 1}: ${u.question}`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Button
|
||||
className="bg-red-500 px-3 py-1 text-sm"
|
||||
onClick={() => handleDelete(item.fileName)}
|
||||
disabled={item.usedBy.length > 0 || deleting[item.fileName]}
|
||||
>
|
||||
{deleting[item.fileName] ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td className="p-3 text-sm text-gray-500" colSpan={5}>
|
||||
No media uploaded yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MediaLibrary
|
||||
@@ -33,7 +33,7 @@ type MediaLibraryItem = {
|
||||
const blankQuestion = (): EditableQuestion => ({
|
||||
question: "",
|
||||
answers: ["", ""],
|
||||
solution: 0,
|
||||
solution: [0],
|
||||
cooldown: 5,
|
||||
time: 20,
|
||||
})
|
||||
@@ -65,6 +65,7 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
||||
const [uploading, setUploading] = useState<Record<number, boolean>>({})
|
||||
const [deleting, setDeleting] = useState<Record<number, boolean>>({})
|
||||
const [refreshingLibrary, setRefreshingLibrary] = useState(false)
|
||||
const [probing, setProbing] = useState<Record<number, boolean>>({})
|
||||
|
||||
useEvent("manager:quizzLoaded", (quizz) => {
|
||||
setDraft(quizz)
|
||||
@@ -79,6 +80,15 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
||||
refreshMediaLibrary()
|
||||
})
|
||||
|
||||
useEvent("manager:quizzDeleted", (id) => {
|
||||
toast.success("Quiz deleted")
|
||||
if (selectedId === id) {
|
||||
setSelectedId(null)
|
||||
setDraft(null)
|
||||
}
|
||||
refreshMediaLibrary()
|
||||
})
|
||||
|
||||
useEvent("manager:quizzList", (list) => {
|
||||
onListUpdate(list)
|
||||
})
|
||||
@@ -129,6 +139,13 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteQuizz = () => {
|
||||
if (!selectedId) return
|
||||
if (!window.confirm("Delete this quiz?")) return
|
||||
setSaving(true)
|
||||
socket?.emit("manager:deleteQuizz", { id: selectedId })
|
||||
}
|
||||
|
||||
const updateQuestion = (
|
||||
index: number,
|
||||
patch: Partial<EditableQuestion>,
|
||||
@@ -169,10 +186,14 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
||||
return
|
||||
}
|
||||
currentAnswers.splice(aIndex, 1)
|
||||
let nextSolution = nextQuestions[qIndex].solution
|
||||
if (nextSolution >= currentAnswers.length) {
|
||||
nextSolution = currentAnswers.length - 1
|
||||
}
|
||||
const currentSolution = Array.isArray(nextQuestions[qIndex].solution)
|
||||
? nextQuestions[qIndex].solution
|
||||
: [nextQuestions[qIndex].solution]
|
||||
const adjusted = currentSolution
|
||||
.filter((idx) => idx !== aIndex)
|
||||
.map((idx) => (idx > aIndex ? idx - 1 : idx))
|
||||
const nextSolution =
|
||||
adjusted.length > 0 ? adjusted : [Math.max(0, currentAnswers.length - 1)]
|
||||
nextQuestions[qIndex] = {
|
||||
...nextQuestions[qIndex],
|
||||
answers: currentAnswers,
|
||||
@@ -263,6 +284,91 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
||||
setQuestionMedia(qIndex, undefined)
|
||||
}
|
||||
|
||||
const probeMediaDuration = async (url: string, type: QuestionMedia["type"]) => {
|
||||
if (!url || (type !== "audio" && type !== "video")) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const el = document.createElement(type)
|
||||
el.crossOrigin = "anonymous"
|
||||
el.preload = "metadata"
|
||||
el.src = url
|
||||
el.load()
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
el.onloadedmetadata = null
|
||||
el.onloadeddata = null
|
||||
el.oncanplaythrough = null
|
||||
el.onerror = null
|
||||
}
|
||||
const done = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
el.onloadedmetadata = done
|
||||
el.onloadeddata = done
|
||||
el.oncanplaythrough = done
|
||||
el.onerror = () => {
|
||||
cleanup()
|
||||
reject(new Error("Failed to load media metadata"))
|
||||
}
|
||||
// safety timeout
|
||||
setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error("Timed out loading media metadata"))
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
const duration = el.duration
|
||||
return Number.isFinite(duration) && duration > 0 ? duration : null
|
||||
} catch (error) {
|
||||
console.warn("Failed to probe media duration", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const adjustTimingWithMedia = async (
|
||||
qIndex: number,
|
||||
media: QuestionMedia | undefined,
|
||||
) => {
|
||||
if (!draft || !media?.url || !media.type || media.type === "image") {
|
||||
return
|
||||
}
|
||||
|
||||
setProbing((prev) => ({ ...prev, [qIndex]: true }))
|
||||
|
||||
try {
|
||||
const duration = await probeMediaDuration(media.url, media.type)
|
||||
if (!duration || !draft) {
|
||||
return
|
||||
}
|
||||
|
||||
const rounded = Math.ceil(duration)
|
||||
const buffer = 3
|
||||
const minCooldown = rounded
|
||||
const minAnswer = rounded + buffer
|
||||
const question = draft.questions[qIndex]
|
||||
|
||||
const nextCooldown = Math.max(question.cooldown, minCooldown)
|
||||
const nextTime = Math.max(question.time, minAnswer)
|
||||
|
||||
if (nextCooldown !== question.cooldown || nextTime !== question.time) {
|
||||
updateQuestion(qIndex, {
|
||||
cooldown: nextCooldown,
|
||||
time: nextTime,
|
||||
})
|
||||
toast.success(
|
||||
`Adjusted timing to media length (~${rounded}s, answers ${nextTime}s)`,
|
||||
{ id: `timing-${qIndex}` },
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
setProbing((prev) => ({ ...prev, [qIndex]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleMediaUpload = async (qIndex: number, file: File) => {
|
||||
if (!draft) return
|
||||
const question = draft.questions[qIndex]
|
||||
@@ -359,15 +465,20 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-6xl flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={onBack} className="bg-gray-700">
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleNew} className="bg-blue-600">
|
||||
New quiz
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={onBack} className="bg-gray-700">
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleNew} className="bg-blue-600">
|
||||
New quiz
|
||||
</Button>
|
||||
{selectedId && (
|
||||
<Button className="bg-red-600" onClick={handleDeleteQuizz} disabled={saving}>
|
||||
Delete quiz
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} disabled={saving || loading}>
|
||||
{saving ? "Saving..." : "Save quiz"}
|
||||
@@ -509,18 +620,20 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border border-gray-200 p-3">
|
||||
<div className="flex items-center justify-between text-sm font-semibold text-gray-600">
|
||||
<span>Media upload</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{isUploading
|
||||
? "Uploading..."
|
||||
: refreshingLibrary
|
||||
? "Refreshing..."
|
||||
: mediaFileName
|
||||
? "Stored"
|
||||
: "Not saved"}
|
||||
</span>
|
||||
<div className="flex flex-col gap-2 rounded-md border border-gray-200 p-3">
|
||||
<div className="flex items-center justify-between text-sm font-semibold text-gray-600">
|
||||
<span>Media upload</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{isUploading
|
||||
? "Uploading..."
|
||||
: probing[qIndex]
|
||||
? "Probing..."
|
||||
: refreshingLibrary
|
||||
? "Refreshing..."
|
||||
: mediaFileName
|
||||
? "Stored"
|
||||
: "Not saved"}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
@@ -565,26 +678,41 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold text-gray-600">
|
||||
Or paste an external URL
|
||||
</span>
|
||||
<Input
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold text-gray-600">
|
||||
Or paste an external URL
|
||||
</span>
|
||||
<Input
|
||||
value={question.media?.url || question.image || ""}
|
||||
onChange={(e) => handleMediaUrlChange(qIndex, e.target.value)}
|
||||
placeholder="https://..."
|
||||
disabled={!question.media?.type}
|
||||
/>
|
||||
<span className="text-xs text-gray-500">
|
||||
Tip: set answer time longer than the clip duration.
|
||||
</span>
|
||||
</label>
|
||||
<span className="text-xs text-gray-500">
|
||||
Tip: set answer time longer than the clip duration.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{question.media?.type !== "image" && question.media?.url && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
className="bg-gray-700"
|
||||
onClick={() => clearQuestionMedia(qIndex)}
|
||||
disabled={!question.media}
|
||||
className="bg-gray-800"
|
||||
onClick={() => adjustTimingWithMedia(qIndex, question.media)}
|
||||
disabled={probing[qIndex]}
|
||||
>
|
||||
{probing[qIndex] ? "Probing..." : "Set timing from media"}
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">
|
||||
Probes audio/video duration and bumps cooldown/answer time if needed.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="bg-gray-700"
|
||||
onClick={() => clearQuestionMedia(qIndex)}
|
||||
disabled={!question.media}
|
||||
>
|
||||
Clear from question
|
||||
</Button>
|
||||
@@ -611,30 +739,47 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{question.answers.map((answer, aIndex) => (
|
||||
<div
|
||||
key={aIndex}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 rounded-md border p-2",
|
||||
question.solution === aIndex
|
||||
? "border-green-500"
|
||||
: "border-gray-200",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`solution-${qIndex}`}
|
||||
checked={question.solution === aIndex}
|
||||
onChange={() =>
|
||||
updateQuestion(qIndex, { solution: aIndex })
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{question.answers.map((answer, aIndex) => (
|
||||
<div
|
||||
key={aIndex}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 rounded-md border p-2",
|
||||
(Array.isArray(question.solution)
|
||||
? question.solution.includes(aIndex)
|
||||
: question.solution === aIndex)
|
||||
? "border-green-500"
|
||||
: "border-gray-200",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={`solution-${qIndex}-${aIndex}`}
|
||||
checked={
|
||||
Array.isArray(question.solution)
|
||||
? question.solution.includes(aIndex)
|
||||
: question.solution === aIndex
|
||||
}
|
||||
onChange={(e) => {
|
||||
const current = Array.isArray(question.solution)
|
||||
? question.solution
|
||||
: [question.solution]
|
||||
let next = current
|
||||
if (e.target.checked) {
|
||||
next = Array.from(new Set([...current, aIndex])).sort(
|
||||
(a, b) => a - b,
|
||||
)
|
||||
} else {
|
||||
next = current.filter((idx) => idx !== aIndex)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
className="flex-1"
|
||||
value={answer}
|
||||
onChange={(e) =>
|
||||
updateAnswer(qIndex, aIndex, e.target.value)
|
||||
updateQuestion(qIndex, { solution: next })
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="flex-1"
|
||||
value={answer}
|
||||
onChange={(e) =>
|
||||
updateAnswer(qIndex, aIndex, e.target.value)
|
||||
}
|
||||
placeholder={`Answer ${aIndex + 1}`}
|
||||
/>
|
||||
|
||||
@@ -8,9 +8,10 @@ type Props = {
|
||||
quizzList: QuizzWithId[]
|
||||
onSelect: (_id: string) => void
|
||||
onManage?: () => void
|
||||
onMedia?: () => void
|
||||
}
|
||||
|
||||
const SelectQuizz = ({ quizzList, onSelect, onManage }: Props) => {
|
||||
const SelectQuizz = ({ quizzList, onSelect, onManage, onMedia }: Props) => {
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
|
||||
const handleSelect = (id: string) => () => {
|
||||
@@ -35,14 +36,24 @@ const SelectQuizz = ({ quizzList, onSelect, onManage }: Props) => {
|
||||
<div className="z-10 flex w-full max-w-md flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Select a quizz</h1>
|
||||
{onManage && (
|
||||
<button
|
||||
className="text-sm font-semibold text-primary underline"
|
||||
onClick={onManage}
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{onMedia && (
|
||||
<button
|
||||
className="text-sm font-semibold text-gray-700 underline"
|
||||
onClick={onMedia}
|
||||
>
|
||||
Media
|
||||
</button>
|
||||
)}
|
||||
{onManage && (
|
||||
<button
|
||||
className="text-sm font-semibold text-primary underline"
|
||||
onClick={onManage}
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="w-full space-y-2">
|
||||
|
||||
@@ -33,6 +33,10 @@ const Username = () => {
|
||||
useEvent("game:successJoin", (gameId) => {
|
||||
setStatus(STATUS.WAIT, { text: "Waiting for the players" })
|
||||
login(username)
|
||||
try {
|
||||
localStorage.setItem("last_game_id", gameId)
|
||||
localStorage.setItem("last_username", username)
|
||||
} catch {}
|
||||
|
||||
router.replace(`/game/${gameId}`)
|
||||
})
|
||||
|
||||
@@ -21,15 +21,26 @@ type Props = {
|
||||
}
|
||||
|
||||
const Answers = ({
|
||||
data: { question, answers, image, media, time, totalPlayer },
|
||||
data: {
|
||||
question,
|
||||
answers,
|
||||
image,
|
||||
media,
|
||||
time,
|
||||
totalPlayer,
|
||||
allowsMultiple,
|
||||
},
|
||||
}: Props) => {
|
||||
const { gameId }: { gameId?: string } = useParams()
|
||||
const { socket } = useSocket()
|
||||
const { player } = usePlayerStore()
|
||||
|
||||
const [cooldown, setCooldown] = useState(time)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [totalAnswer, setTotalAnswer] = useState(0)
|
||||
const [isMediaPlaying, setIsMediaPlaying] = useState(false)
|
||||
const [selectedAnswers, setSelectedAnswers] = useState<number[]>([])
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
const [sfxPop] = useSound(SFX_ANSWERS_SOUND, {
|
||||
volume: 0.1,
|
||||
@@ -44,20 +55,39 @@ const Answers = ({
|
||||
},
|
||||
)
|
||||
|
||||
const handleAnswer = (answerKey: number) => () => {
|
||||
if (!player) {
|
||||
const submitAnswers = (keys: number[]) => {
|
||||
if (!player || submitted || keys.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
socket?.emit("player:selectedAnswer", {
|
||||
gameId,
|
||||
data: {
|
||||
answerKey,
|
||||
answerKeys: keys,
|
||||
},
|
||||
})
|
||||
setSubmitted(true)
|
||||
sfxPop()
|
||||
}
|
||||
|
||||
const handleAnswer = (answerKey: number) => () => {
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!allowsMultiple) {
|
||||
setSelectedAnswers([answerKey])
|
||||
submitAnswers([answerKey])
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedAnswers((prev) =>
|
||||
prev.includes(answerKey)
|
||||
? prev.filter((key) => key !== answerKey)
|
||||
: [...prev, answerKey],
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
playMusic()
|
||||
|
||||
@@ -78,17 +108,34 @@ const Answers = ({
|
||||
setCooldown(sec)
|
||||
})
|
||||
|
||||
useEvent("game:cooldownPause", (isPaused) => {
|
||||
setPaused(isPaused)
|
||||
})
|
||||
|
||||
useEvent("game:playerAnswer", (count) => {
|
||||
setTotalAnswer(count)
|
||||
sfxPop()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setCooldown(time)
|
||||
setPaused(false)
|
||||
setSelectedAnswers([])
|
||||
setSubmitted(false)
|
||||
setTotalAnswer(0)
|
||||
}, [question, time])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-1 flex-col justify-between">
|
||||
<div className="mx-auto inline-flex h-full w-full max-w-7xl flex-1 flex-col items-center justify-center gap-5">
|
||||
<h2 className="text-center text-2xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
|
||||
{question}
|
||||
</h2>
|
||||
{allowsMultiple && (
|
||||
<p className="rounded-full bg-black/40 px-4 py-2 text-sm font-semibold text-white">
|
||||
Select all correct answers, then submit.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<QuestionMedia
|
||||
media={media || (image ? { type: "image", url: image } : undefined)}
|
||||
@@ -102,6 +149,11 @@ const Answers = ({
|
||||
<div className="flex flex-col items-center rounded-full bg-black/40 px-4 text-lg font-bold">
|
||||
<span className="translate-y-1 text-sm">Time</span>
|
||||
<span>{cooldown}</span>
|
||||
{paused && (
|
||||
<span className="text-xs font-semibold uppercase text-amber-200">
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-center rounded-full bg-black/40 px-4 text-lg font-bold">
|
||||
<span className="translate-y-1 text-sm">Answers</span>
|
||||
@@ -118,11 +170,29 @@ const Answers = ({
|
||||
className={clsx(ANSWERS_COLORS[key])}
|
||||
icon={ANSWERS_ICONS[key]}
|
||||
onClick={handleAnswer(key)}
|
||||
disabled={submitted}
|
||||
selected={selectedAnswers.includes(key)}
|
||||
>
|
||||
{answer}
|
||||
</AnswerButton>
|
||||
))}
|
||||
</div>
|
||||
{allowsMultiple && (
|
||||
<div className="mx-auto flex w-full max-w-7xl justify-end px-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submitAnswers(selectedAnswers)}
|
||||
disabled={submitted || selectedAnswers.length === 0}
|
||||
className={clsx(
|
||||
"rounded bg-white/80 px-4 py-2 text-sm font-semibold text-slate-900 shadow-md transition hover:bg-white",
|
||||
(submitted || selectedAnswers.length === 0) &&
|
||||
"cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
{submitted ? "Submitted" : "Submit answers"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
|
||||
import QuestionMedia from "@rahoot/web/components/game/QuestionMedia"
|
||||
import { useEvent } from "@rahoot/web/contexts/socketProvider"
|
||||
import { SFX_SHOW_SOUND } from "@rahoot/web/utils/constants"
|
||||
import { useEffect } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import useSound from "use-sound"
|
||||
|
||||
type Props = {
|
||||
@@ -12,11 +13,23 @@ type Props = {
|
||||
|
||||
const Question = ({ data: { question, image, media, cooldown } }: Props) => {
|
||||
const [sfxShow] = useSound(SFX_SHOW_SOUND, { volume: 0.5 })
|
||||
const [seconds, setSeconds] = useState(cooldown)
|
||||
const [paused, setPaused] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
sfxShow()
|
||||
}, [sfxShow])
|
||||
|
||||
useEvent("game:cooldown", (sec) => {
|
||||
setSeconds(sec)
|
||||
})
|
||||
|
||||
useEvent("game:cooldownPause", (isPaused) => {
|
||||
setPaused(isPaused)
|
||||
})
|
||||
|
||||
const percent = Math.max(0, Math.min(100, (seconds / cooldown) * 100))
|
||||
|
||||
return (
|
||||
<section className="relative mx-auto flex h-full w-full max-w-7xl flex-1 flex-col items-center px-4">
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-5">
|
||||
@@ -29,10 +42,17 @@ const Question = ({ data: { question, image, media, cooldown } }: Props) => {
|
||||
alt={question}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="bg-primary mb-20 h-4 self-start justify-self-end rounded-full"
|
||||
style={{ animation: `progressBar ${cooldown}s linear forwards` }}
|
||||
></div>
|
||||
<div className="mb-20 h-4 w-full max-w-4xl self-start overflow-hidden rounded-full bg-white/30">
|
||||
<div
|
||||
className="h-full bg-primary transition-[width]"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{paused && (
|
||||
<div className="absolute bottom-6 right-6 rounded-md bg-black/60 px-3 py-1 text-sm font-semibold text-white">
|
||||
Paused
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ const Responses = ({
|
||||
stopMusic()
|
||||
}, [playMusic, stopMusic])
|
||||
|
||||
const correctSet = new Set(
|
||||
Array.isArray(correct) ? correct : typeof correct === "number" ? [correct] : [],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-1 flex-col justify-between">
|
||||
<div className="mx-auto inline-flex h-full w-full max-w-7xl flex-1 flex-col items-center justify-center gap-5">
|
||||
@@ -107,7 +111,7 @@ const Responses = ({
|
||||
<AnswerButton
|
||||
key={key}
|
||||
className={clsx(ANSWERS_COLORS[key], {
|
||||
"opacity-65": responses && correct !== key,
|
||||
"opacity-65": responses && !correctSet.has(key),
|
||||
})}
|
||||
icon={ANSWERS_ICONS[key]}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,11 @@ import fs from "fs"
|
||||
import { promises as fsp } from "fs"
|
||||
import path from "path"
|
||||
|
||||
const toBytes = (valueMb: number) => valueMb * 1024 * 1024
|
||||
|
||||
const envMaxMb = Number(process.env.MEDIA_MAX_UPLOAD_MB || process.env.MAX_UPLOAD_MB || 50)
|
||||
const MAX_UPLOAD_SIZE = Number.isFinite(envMaxMb) && envMaxMb > 0 ? toBytes(envMaxMb) : toBytes(50)
|
||||
|
||||
export type StoredMedia = {
|
||||
fileName: string
|
||||
url: string
|
||||
@@ -18,8 +23,6 @@ export type StoredMedia = {
|
||||
}[]
|
||||
}
|
||||
|
||||
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024 // 50MB
|
||||
|
||||
const ensureMediaFolder = () => {
|
||||
Config.ensureBaseFolders()
|
||||
const folder = Config.getMediaPath()
|
||||
@@ -34,19 +37,30 @@ const ensureMediaFolder = () => {
|
||||
const inferMimeFromName = (fileName: string) => {
|
||||
const ext = path.extname(fileName).toLowerCase()
|
||||
|
||||
if ([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"].includes(ext)) {
|
||||
return `image/${ext.replace(".", "") || "jpeg"}`
|
||||
const map: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".svg": "image/svg+xml",
|
||||
".mp3": "audio/mpeg",
|
||||
".m4a": "audio/mp4",
|
||||
".aac": "audio/aac",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".oga": "audio/ogg",
|
||||
".flac": "audio/flac",
|
||||
".mp4": "video/mp4",
|
||||
".m4v": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".webm": "video/webm",
|
||||
".ogv": "video/ogg",
|
||||
".mkv": "video/x-matroska",
|
||||
}
|
||||
|
||||
if ([".mp3", ".wav", ".ogg", ".aac", ".m4a", ".flac"].includes(ext)) {
|
||||
return `audio/${ext.replace(".", "") || "mpeg"}`
|
||||
}
|
||||
|
||||
if ([".mp4", ".webm", ".mov", ".ogv", ".mkv"].includes(ext)) {
|
||||
return `video/${ext.replace(".", "") || "mp4"}`
|
||||
}
|
||||
|
||||
return "application/octet-stream"
|
||||
return map[ext] || "application/octet-stream"
|
||||
}
|
||||
|
||||
const inferMediaType = (mime: string): QuestionMedia["type"] | null => {
|
||||
@@ -172,7 +186,9 @@ export const storeMediaFile = async (file: File): Promise<StoredMedia> => {
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
|
||||
if (buffer.byteLength > MAX_UPLOAD_SIZE) {
|
||||
throw new Error("File is too large. Max 50MB.")
|
||||
throw new Error(
|
||||
`File is too large. Max ${Math.round(MAX_UPLOAD_SIZE / 1024 / 1024)}MB.`,
|
||||
)
|
||||
}
|
||||
|
||||
const targetFolder = ensureMediaFolder()
|
||||
|
||||
@@ -11,7 +11,7 @@ type ManagerStore<T> = {
|
||||
setGameId: (_gameId: string | null) => void
|
||||
setStatus: <K extends keyof T>(_name: K, _data: T[K]) => void
|
||||
resetStatus: () => void
|
||||
setPlayers: (_players: Player[]) => void
|
||||
setPlayers: (_players: Player[] | ((_prev: Player[]) => Player[])) => void
|
||||
|
||||
reset: () => void
|
||||
}
|
||||
@@ -30,7 +30,10 @@ export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({
|
||||
setStatus: (name, data) => set({ status: createStatus(name, data) }),
|
||||
resetStatus: () => set({ status: null }),
|
||||
|
||||
setPlayers: (players) => set({ players }),
|
||||
setPlayers: (players) =>
|
||||
set((state) => ({
|
||||
players: typeof players === "function" ? players(state.players) : players,
|
||||
})),
|
||||
|
||||
reset: () => set(initialState),
|
||||
}))
|
||||
|
||||
@@ -35,11 +35,24 @@ export const usePlayerStore = create<PlayerStore<StatusDataMap>>((set) => ({
|
||||
|
||||
setGameId: (gameId) => set({ gameId }),
|
||||
|
||||
setPlayer: (player: PlayerState) => set({ player }),
|
||||
setPlayer: (player: PlayerState) => {
|
||||
try {
|
||||
if (player.username) localStorage.setItem("last_username", player.username)
|
||||
if (typeof player.points === "number") {
|
||||
localStorage.setItem("last_points", String(player.points))
|
||||
}
|
||||
} catch {}
|
||||
set({ player })
|
||||
},
|
||||
login: (username) =>
|
||||
set((state) => ({
|
||||
player: { ...state.player, username },
|
||||
})),
|
||||
set((state) => {
|
||||
try {
|
||||
localStorage.setItem("last_username", username)
|
||||
} catch {}
|
||||
return {
|
||||
player: { ...state.player, username },
|
||||
}
|
||||
}),
|
||||
|
||||
join: (gameId) => {
|
||||
set((state) => ({
|
||||
@@ -48,12 +61,22 @@ export const usePlayerStore = create<PlayerStore<StatusDataMap>>((set) => ({
|
||||
}))
|
||||
},
|
||||
|
||||
updatePoints: (points) =>
|
||||
updatePoints: (points) => {
|
||||
try {
|
||||
localStorage.setItem("last_points", String(points))
|
||||
} catch {}
|
||||
set((state) => ({
|
||||
player: { ...state.player, points },
|
||||
})),
|
||||
}))
|
||||
},
|
||||
|
||||
setStatus: (name, data) => set({ status: createStatus(name, data) }),
|
||||
|
||||
reset: () => set(initialState),
|
||||
reset: () => {
|
||||
try {
|
||||
localStorage.removeItem("last_username")
|
||||
localStorage.removeItem("last_points")
|
||||
} catch {}
|
||||
set(initialState)
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -66,7 +66,7 @@ export const MANAGER_SKIP_BTN = {
|
||||
[STATUS.SHOW_ROOM]: "Start Game",
|
||||
[STATUS.SHOW_START]: null,
|
||||
[STATUS.SHOW_PREPARED]: null,
|
||||
[STATUS.SHOW_QUESTION]: null,
|
||||
[STATUS.SHOW_QUESTION]: "Skip",
|
||||
[STATUS.SELECT_ANSWER]: "Skip",
|
||||
[STATUS.SHOW_RESULT]: null,
|
||||
[STATUS.SHOW_RESPONSES]: "Next",
|
||||
|
||||
98
pnpm-lock.yaml
generated
98
pnpm-lock.yaml
generated
@@ -54,6 +54,9 @@ importers:
|
||||
dayjs:
|
||||
specifier: ^1.11.18
|
||||
version: 1.11.18
|
||||
redis:
|
||||
specifier: ^4.6.13
|
||||
version: 4.7.1
|
||||
socket.io:
|
||||
specifier: ^4.8.1
|
||||
version: 4.8.1
|
||||
@@ -693,6 +696,35 @@ packages:
|
||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||
engines: {node: '>=12.4.0'}
|
||||
|
||||
'@redis/bloom@1.2.0':
|
||||
resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==}
|
||||
peerDependencies:
|
||||
'@redis/client': ^1.0.0
|
||||
|
||||
'@redis/client@1.6.1':
|
||||
resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@redis/graph@1.1.1':
|
||||
resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==}
|
||||
peerDependencies:
|
||||
'@redis/client': ^1.0.0
|
||||
|
||||
'@redis/json@1.0.7':
|
||||
resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==}
|
||||
peerDependencies:
|
||||
'@redis/client': ^1.0.0
|
||||
|
||||
'@redis/search@1.2.0':
|
||||
resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==}
|
||||
peerDependencies:
|
||||
'@redis/client': ^1.0.0
|
||||
|
||||
'@redis/time-series@1.1.0':
|
||||
resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==}
|
||||
peerDependencies:
|
||||
'@redis/client': ^1.0.0
|
||||
|
||||
'@rtsao/scc@1.1.0':
|
||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||
|
||||
@@ -1145,6 +1177,10 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
cluster-key-slot@1.1.2:
|
||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -1529,6 +1565,10 @@ packages:
|
||||
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
generic-pool@3.9.0:
|
||||
resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
gensync@1.0.0-beta.2:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -1962,6 +2002,7 @@ packages:
|
||||
next@15.5.4:
|
||||
resolution: {integrity: sha512-xH4Yjhb82sFYQfY3vbkJfgSDgXvBB6a8xPs9i35k6oZJRoQRihZH+4s9Yo2qsWpzBmZ3lPXaJ2KPXLfkvW4LnA==}
|
||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||
deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.1.0
|
||||
@@ -2177,6 +2218,9 @@ packages:
|
||||
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
redis@4.7.1:
|
||||
resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2506,6 +2550,9 @@ packages:
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
yallist@4.0.0:
|
||||
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2957,6 +3004,32 @@ snapshots:
|
||||
|
||||
'@nolyfill/is-core-module@1.0.39': {}
|
||||
|
||||
'@redis/bloom@1.2.0(@redis/client@1.6.1)':
|
||||
dependencies:
|
||||
'@redis/client': 1.6.1
|
||||
|
||||
'@redis/client@1.6.1':
|
||||
dependencies:
|
||||
cluster-key-slot: 1.1.2
|
||||
generic-pool: 3.9.0
|
||||
yallist: 4.0.0
|
||||
|
||||
'@redis/graph@1.1.1(@redis/client@1.6.1)':
|
||||
dependencies:
|
||||
'@redis/client': 1.6.1
|
||||
|
||||
'@redis/json@1.0.7(@redis/client@1.6.1)':
|
||||
dependencies:
|
||||
'@redis/client': 1.6.1
|
||||
|
||||
'@redis/search@1.2.0(@redis/client@1.6.1)':
|
||||
dependencies:
|
||||
'@redis/client': 1.6.1
|
||||
|
||||
'@redis/time-series@1.1.0(@redis/client@1.6.1)':
|
||||
dependencies:
|
||||
'@redis/client': 1.6.1
|
||||
|
||||
'@rtsao/scc@1.1.0': {}
|
||||
|
||||
'@rushstack/eslint-patch@1.14.1': {}
|
||||
@@ -3393,6 +3466,8 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
cluster-key-slot@1.1.2: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -3674,7 +3749,7 @@ snapshots:
|
||||
'@typescript-eslint/parser': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.38.0(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.38.0(jiti@2.6.1))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1)))(eslint@9.38.0(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.38.0(jiti@2.6.1))
|
||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.38.0(jiti@2.6.1))
|
||||
eslint-plugin-react: 7.37.5(eslint@9.38.0(jiti@2.6.1))
|
||||
@@ -3694,7 +3769,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.38.0(jiti@2.6.1)):
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1)))(eslint@9.38.0(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3
|
||||
@@ -3709,14 +3784,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.38.0(jiti@2.6.1)):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1)))(eslint@9.38.0(jiti@2.6.1)))(eslint@9.38.0(jiti@2.6.1)):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.38.0(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.38.0(jiti@2.6.1))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1)))(eslint@9.38.0(jiti@2.6.1))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -3731,7 +3806,7 @@ snapshots:
|
||||
doctrine: 2.1.0
|
||||
eslint: 9.38.0(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.38.0(jiti@2.6.1))
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1)))(eslint@9.38.0(jiti@2.6.1)))(eslint@9.38.0(jiti@2.6.1))
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
@@ -3953,6 +4028,8 @@ snapshots:
|
||||
|
||||
generator-function@2.0.1: {}
|
||||
|
||||
generic-pool@3.9.0: {}
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
@@ -4499,6 +4576,15 @@ snapshots:
|
||||
|
||||
react@19.1.0: {}
|
||||
|
||||
redis@4.7.1:
|
||||
dependencies:
|
||||
'@redis/bloom': 1.2.0(@redis/client@1.6.1)
|
||||
'@redis/client': 1.6.1
|
||||
'@redis/graph': 1.1.1(@redis/client@1.6.1)
|
||||
'@redis/json': 1.0.7(@redis/client@1.6.1)
|
||||
'@redis/search': 1.2.0(@redis/client@1.6.1)
|
||||
'@redis/time-series': 1.1.0(@redis/client@1.6.1)
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@@ -4967,6 +5053,8 @@ snapshots:
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yallist@4.0.0: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yup@1.7.1:
|
||||
|
||||
Reference in New Issue
Block a user