32 Commits

Author SHA1 Message Date
RandyJC
55349e01f6 adding multiple choice (not fully tested yet) 2025-12-09 08:52:07 +01:00
RandyJC
82be8dee93 update README 2025-12-08 23:37:39 +01:00
RandyJC
656c4efddd fix points and username after restoring session 2025-12-08 23:25:26 +01:00
RandyJC
8890808751 fix 2025-12-08 23:19:07 +01:00
RandyJC
113950cc6f small fix when reconnecting to the game 2025-12-08 23:17:21 +01:00
RandyJC
d8a9fb2dca fix rejoining the game for some clients 2025-12-08 23:12:05 +01:00
RandyJC
3317925d08 fix build error 2025-12-08 23:05:24 +01:00
RandyJC
d66b03e797 adding new clients view for manager page 2025-12-08 23:03:46 +01:00
RandyJC
c7d41cd7a5 adding more persistence for the client to be able to rejoin the session on clientId 2025-12-08 22:50:52 +01:00
RandyJC
42df8f5893 no-lock fix 2025-12-08 22:41:39 +01:00
RandyJC
befe39d2fd adding persistance when client losing connections (adding redis db) 2025-12-08 22:37:44 +01:00
RandyJC
7129ec6984 fix build error 2025-12-08 22:12:25 +01:00
RandyJC
e2df9c20cc fix build error 2025-12-08 22:10:29 +01:00
RandyJC
01b26c59f0 adding asset manager and other new features 2025-12-08 22:08:06 +01:00
RandyJC
03fdeb643a fix probing on wav 2025-12-08 21:51:49 +01:00
RandyJC
6420544f35 adding probe button in quizeditor and fix player size in cooldown 2025-12-08 21:44:01 +01:00
RandyJC
03b00b2499 adding skip in cooldown and probing for media files 2025-12-08 21:32:27 +01:00
RandyJC
ea49971609 adding .env for max file upload 2025-12-08 21:18:32 +01:00
RandyJC
e60818f93e fix 2025-12-08 20:56:17 +01:00
RandyJC
5499a83a9f fix media play on safari browser 2025-12-08 20:51:25 +01:00
RandyJC
ce89a023c8 fix for safari browser handling media files 2025-12-08 16:00:54 +01:00
RandyJC
fd3047ec04 fix build error 2025-12-08 15:52:05 +01:00
RandyJC
8403c6a7c3 fix build error 2025-12-08 15:49:34 +01:00
RandyJC
650e8f2366 fix build error 2025-12-08 15:48:06 +01:00
RandyJC
a10cea357b fix build 2025-12-08 15:45:04 +01:00
RandyJC
eefb5b01f1 fix build error 2025-12-08 15:43:32 +01:00
RandyJC
e5fd5d52f0 remove youtube support and add local file handling 2025-12-08 15:38:23 +01:00
RandyJC
df615dc720 youtube fix 2025-12-08 15:09:42 +01:00
RandyJC
1988eca947 socket fix 2025-12-08 13:40:41 +01:00
RandyJC
59ee57d995 another fix for npmn 2025-12-08 13:38:25 +01:00
RandyJC
87e9864290 fixed issue witn npmn 2025-12-08 13:35:52 +01:00
RandyJC
14ea9c75cd adding manager UI and audio and video (youtube) questions 2025-11-28 21:17:18 +01:00
35 changed files with 2641 additions and 169 deletions

View File

@@ -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:
@@ -134,6 +131,7 @@ Example quiz configuration (`config/quizz/example.json`):
"question": "What is the correct answer?",
"answers": ["No", "Yes", "No", "No"],
"image": "https://images.unsplash.com/....",
"media": { "type": "audio", "url": "https://example.com/song.mp3" },
"solution": 1,
"cooldown": 5,
"time": 15
@@ -148,11 +146,31 @@ Quiz Options:
- `questions`: Array of question objects containing:
- `question`: The question text
- `answers`: Array of possible answers (2-4 options)
- `image`: Optional URL for question image
- `image`: Optional URL for question image (legacy; use `media` for new content)
- `media`: Optional media attachment `{ "type": "image" | "audio" | "video", "url": "<link>" }`. Examples:
- `{"type":"audio","url":"https://.../clip.mp3"}`
- `{"type":"video","url":"https://.../clip.mp4"}`
- `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 theyre 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

View File

@@ -2,27 +2,36 @@
"subject": "Example Quizz",
"questions": [
{
"question": "What is good answer ?",
"answers": ["No", "Good answer", "No", "No"],
"question": "Which soundtrack is this?",
"answers": [
"Nature sounds",
"Piano solo",
"Electronic beat",
"Chill guitar"
],
"media": {
"type": "audio",
"url": "https://file-examples.com/storage/fee95f49ad692e9489b0fab/2017/11/file_example_WAV_1MG.wav"
},
"solution": 1,
"cooldown": 5,
"time": 15
"time": 25
},
{
"question": "What is good answer with image ?",
"answers": ["No", "No", "No", "Good answer"],
"image": "https://placehold.co/600x400.png",
"question": "Which landmark appears in this clip?",
"answers": [
"Eiffel Tower",
"Sydney Opera House",
"Statue of Liberty",
"Golden Gate Bridge"
],
"media": {
"type": "youtube",
"url": "https://www.youtube.com/watch?v=jNQXAC9IVRw"
},
"solution": 3,
"cooldown": 5,
"time": 20
},
{
"question": "What is good answer with two answers ?",
"answers": ["Good answer", "No"],
"image": "https://placehold.co/600x400.png",
"solution": 0,
"cooldown": 5,
"time": 20
"time": 60
}
]
}
}

View File

@@ -8,7 +8,7 @@ export type Player = {
export type Answer = {
playerId: string
answerId: number
answerIds: number[]
points: number
}
@@ -17,13 +17,19 @@ export type Quizz = {
questions: {
question: string
image?: string
media?: QuestionMedia
answers: string[]
solution: number
solution: number | number[]
cooldown: number
time: number
}[]
}
export type QuestionMedia =
| { type: "image"; url: string; fileName?: string }
| { type: "audio"; url: string; fileName?: string }
| { type: "video"; url: string; fileName?: string }
export type QuizzWithId = Quizz & { id: string }
export type GameUpdateQuestion = {

View File

@@ -1,5 +1,5 @@
import { Server as ServerIO, Socket as SocketIO } from "socket.io"
import { GameUpdateQuestion, Player, QuizzWithId } from "."
import { GameUpdateQuestion, Player, Quizz, QuizzWithId } from "."
import { Status, StatusDataMap } from "./status"
export type Server = ServerIO<ClientToServerEvents, ServerToClientEvents>
@@ -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,8 +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 {
@@ -71,15 +76,22 @@ 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
// Player actions
"player:join": (_inviteCode: string) => void
"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

View File

@@ -1,4 +1,4 @@
import { Player } from "."
import { Player, QuestionMedia } from "."
export const STATUS = {
SHOW_ROOM: "SHOW_ROOM",
@@ -18,13 +18,20 @@ export type Status = (typeof STATUS)[keyof typeof STATUS]
export type CommonStatusDataMap = {
SHOW_START: { time: number; subject: string }
SHOW_PREPARED: { totalAnswers: number; questionNumber: number }
SHOW_QUESTION: { question: string; image?: string; cooldown: number }
SHOW_QUESTION: {
question: string
image?: string
media?: QuestionMedia
cooldown: number
}
SELECT_ANSWER: {
question: string
answers: string[]
image?: string
media?: QuestionMedia
time: number
totalPlayer: number
allowsMultiple: boolean
}
SHOW_RESULT: {
correct: boolean
@@ -43,9 +50,10 @@ type ManagerExtraStatus = {
SHOW_RESPONSES: {
question: string
responses: Record<number, number>
correct: number
correct: number | number[]
answers: string[]
image?: string
media?: QuestionMedia
}
SHOW_LEADERBOARD: { oldLeaderboard: Player[]; leaderboard: Player[] }
}

View File

@@ -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"

View File

@@ -4,12 +4,22 @@ 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"
const corsOrigins =
process.env.NODE_ENV !== "production"
? "*"
: env.WEB_ORIGIN === "*"
? "*"
: [env.WEB_ORIGIN, "http://localhost:3000", "http://127.0.0.1:3000"]
const io: Server = new ServerIO({
cors: {
origin: [env.WEB_ORIGIN],
origin: corsOrigins,
methods: ["GET", "POST"],
credentials: false,
},
})
Config.init()
@@ -25,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)
@@ -34,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)
@@ -46,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) => {
@@ -66,6 +113,63 @@ io.on("connection", (socket) => {
}
})
socket.on("manager:getQuizz", (quizzId) => {
const quizz = Config.getQuizz(quizzId)
if (!quizz) {
socket.emit("manager:errorMessage", "Quizz not found")
return
}
socket.emit("manager:quizzLoaded", quizz)
})
socket.on("manager:saveQuizz", ({ id, quizz }) => {
if (!quizz?.subject || !Array.isArray(quizz?.questions)) {
socket.emit("manager:errorMessage", "Invalid quizz payload")
return
}
try {
const saved = Config.saveQuizz(id || null, quizz)
if (!saved) {
socket.emit("manager:errorMessage", "Failed to save quizz")
return
}
socket.emit("manager:quizzSaved", saved)
socket.emit("manager:quizzList", Config.quizz())
} catch (error) {
console.error("Failed to save quizz", error)
socket.emit("manager:errorMessage", "Failed to save quizz")
}
})
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)
@@ -114,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)
)
)
@@ -122,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())
)
@@ -161,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)
})
})

View File

@@ -2,6 +2,13 @@ import { QuizzWithId } from "@rahoot/common/types/game"
import fs from "fs"
import { resolve } from "path"
const slugify = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 50)
const inContainerPath = process.env.CONFIG_PATH
const getPath = (path: string = "") =>
@@ -9,14 +16,32 @@ const getPath = (path: string = "") =>
? resolve(inContainerPath, path)
: resolve(process.cwd(), "../../config", path)
export const getConfigPath = (path: string = "") => getPath(path)
class Config {
static init() {
static ensureBaseFolders() {
const isConfigFolderExists = fs.existsSync(getPath())
if (!isConfigFolderExists) {
fs.mkdirSync(getPath())
}
const isQuizzExists = fs.existsSync(getPath("quizz"))
if (!isQuizzExists) {
fs.mkdirSync(getPath("quizz"))
}
const isMediaExists = fs.existsSync(getPath("media"))
if (!isMediaExists) {
fs.mkdirSync(getPath("media"))
}
}
static init() {
this.ensureBaseFolders()
const isGameConfigExists = fs.existsSync(getPath("game.json"))
if (!isGameConfigExists) {
@@ -33,10 +58,10 @@ class Config {
)
}
const isQuizzExists = fs.existsSync(getPath("quizz"))
const isQuizzExists = fs.readdirSync(getPath("quizz")).length > 0
if (!isQuizzExists) {
fs.mkdirSync(getPath("quizz"))
fs.mkdirSync(getPath("quizz"), { recursive: true })
fs.writeFileSync(
getPath("quizz/example.json"),
@@ -67,6 +92,38 @@ class Config {
cooldown: 5,
time: 20,
},
{
question: "Which soundtrack is this?",
answers: [
"Nature sounds",
"Piano solo",
"Electronic beat",
"Chill guitar",
],
media: {
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],
cooldown: 5,
time: 25,
},
{
question: "Which landmark appears in this clip?",
answers: [
"Eiffel Tower",
"Sydney Opera House",
"Statue of Liberty",
"Golden Gate Bridge",
],
media: {
type: "video",
url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4",
},
solution: [2],
cooldown: 5,
time: 40,
},
],
},
null,
@@ -95,35 +152,84 @@ class Config {
}
static quizz() {
const isExists = fs.existsSync(getPath("quizz"))
this.ensureBaseFolders()
if (!isExists) {
return []
const files = fs
.readdirSync(getPath("quizz"))
.filter((file) => file.endsWith(".json"))
const quizz: QuizzWithId[] = files.map((file) => {
const data = fs.readFileSync(getPath(`quizz/${file}`), "utf-8")
const config = JSON.parse(data)
const id = file.replace(".json", "")
return {
id,
...config,
}
})
return quizz || []
}
static getQuizz(id: string) {
this.ensureBaseFolders()
const filePath = getPath(`quizz/${id}.json`)
if (!fs.existsSync(filePath)) {
return null
}
try {
const files = fs
.readdirSync(getPath("quizz"))
.filter((file) => file.endsWith(".json"))
const data = fs.readFileSync(filePath, "utf-8")
const quizz: QuizzWithId[] = files.map((file) => {
const data = fs.readFileSync(getPath(`quizz/${file}`), "utf-8")
const config = JSON.parse(data)
return { id, ...JSON.parse(data) } as QuizzWithId
}
const id = file.replace(".json", "")
static saveQuizz(
id: string | null,
quizz: QuizzWithId | Omit<QuizzWithId, "id">
) {
this.ensureBaseFolders()
return {
id,
...config,
}
})
const slug = id
? slugify(id)
: slugify((quizz as any).subject || "quizz")
const finalId = slug.length > 0 ? slug : `quizz-${Date.now()}`
const filePath = getPath(`quizz/${finalId}.json`)
return quizz || []
} catch (error) {
console.error("Failed to read quizz config:", error)
fs.writeFileSync(
filePath,
JSON.stringify(
{
subject: quizz.subject,
questions: quizz.questions,
},
null,
2
)
)
return []
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()
return getPath(fileName ? `media/${fileName}` : "media")
}
}

View File

@@ -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,10 +533,11 @@ class Game {
this.broadcastStatus(STATUS.SHOW_QUESTION, {
question: question.question,
image: question.image,
media: question.media,
cooldown: question.cooldown,
})
await sleep(question.cooldown)
await this.startCooldown(question.cooldown)
if (!this.started) {
return
@@ -361,8 +549,11 @@ class Game {
question: question.question,
answers: question.answers,
image: question.image,
media: question.media,
time: question.time,
totalPlayer: this.players.length,
allowsMultiple:
Array.isArray(question.solution) && question.solution.length > 1,
})
await this.startCooldown(question.time)
@@ -372,6 +563,7 @@ class Game {
}
this.showResults(question)
this.persist()
}
showResults(question: any) {
@@ -381,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
},
{}
@@ -395,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 =
@@ -430,14 +631,16 @@ class Game {
correct: question.solution,
answers: question.answers,
image: question.image,
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]
@@ -449,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),
})
@@ -468,6 +679,7 @@ class Game {
if (this.round.playersAnswers.length === this.players.length) {
this.abortCooldown()
}
this.persist()
}
nextRound(socket: Socket) {
@@ -510,6 +722,7 @@ class Game {
subject: this.quizz.subject,
top: this.leaderboard.slice(0, 3),
})
this.clearPersisted()
return
}
@@ -524,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)
}
}

View 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}`)
}

View File

@@ -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

View File

@@ -17,7 +17,7 @@ const AuthLayout = ({ children }: PropsWithChildren) => {
if (!isConnected) {
return (
<section className="relative flex min-h-screen flex-col items-center justify-center">
<div className="absolute h-full w-full overflow-hidden">
<div className="pointer-events-none absolute h-full w-full overflow-hidden">
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full"></div>
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
</div>
@@ -33,7 +33,7 @@ const AuthLayout = ({ children }: PropsWithChildren) => {
return (
<section className="relative flex min-h-screen flex-col items-center justify-center">
<div className="absolute h-full w-full overflow-hidden">
<div className="pointer-events-none absolute h-full w-full overflow-hidden">
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full"></div>
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
</div>

View File

@@ -3,6 +3,8 @@
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"
@@ -16,6 +18,8 @@ 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)
@@ -39,7 +43,40 @@ const Manager = () => {
return <ManagerPassword onSubmit={handleAuth} />
}
return <SelectQuizz quizzList={quizzList} onSelect={handleCreate} />
if (showEditor) {
return (
<QuizEditor
quizzList={quizzList}
onBack={() => setShowEditor(false)}
onListUpdate={setQuizzList}
/>
)
}
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)}
/>
)
}
export default Manager

View File

@@ -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)
})

View File

@@ -0,0 +1,30 @@
import { deleteMediaFile } from "@rahoot/web/server/media"
import { NextRequest, NextResponse } from "next/server"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export async function DELETE(
_request: NextRequest,
context: { params: Promise<{ file: string }> },
) {
try {
const params = await context.params
const fileParam = params.file
if (!fileParam) {
return NextResponse.json({ error: "Missing file parameter" }, { status: 400 })
}
const decoded = decodeURIComponent(fileParam)
await deleteMediaFile(decoded)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Failed to delete media", error)
const message = error instanceof Error ? error.message : "Failed to delete file"
const status = message.includes("not found") ? 404 : 400
return NextResponse.json({ error: message }, { status })
}
}

View File

@@ -0,0 +1,39 @@
import { listStoredMedia, storeMediaFile } from "@rahoot/web/server/media"
import { NextResponse } from "next/server"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export async function GET() {
try {
const media = await listStoredMedia()
return NextResponse.json({ media })
} catch (error) {
console.error("Failed to list media", error)
return NextResponse.json(
{ error: "Unable to list uploaded media" },
{ status: 500 },
)
}
}
export async function POST(request: Request) {
const formData = await request.formData()
const file = formData.get("file")
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file received" }, { status: 400 })
}
try {
const media = await storeMediaFile(file)
return NextResponse.json({ media })
} catch (error) {
console.error("Failed to store media", error)
const message = error instanceof Error ? error.message : "Failed to upload file"
return NextResponse.json({ error: message }, { status: 400 })
}
}

View File

@@ -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
}

View File

@@ -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>
)

View File

@@ -0,0 +1,86 @@
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"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export async function GET(
_request: NextRequest,
context: { params: Promise<{ file: string }> },
) {
const params = await context.params
const safeName = path.basename(params.file)
if (safeName !== params.file) {
return NextResponse.json({ error: "Invalid file name" }, { status: 400 })
}
const filePath = Config.getMediaPath(safeName)
if (!fs.existsSync(filePath)) {
return NextResponse.json({ error: "File not found" }, { status: 404 })
}
try {
const stat = await fsp.stat(filePath)
const fileSize = stat.size
const mime = mimeForStoredFile(safeName)
const range = _request.headers.get("range")
// Basic range support improves Safari/iOS playback
if (range) {
const bytesPrefix = "bytes="
if (!range.startsWith(bytesPrefix)) {
return new NextResponse(null, { status: 416 })
}
const [rawStart, rawEnd] = range.replace(bytesPrefix, "").split("-")
const start = Number(rawStart)
const end = rawEnd ? Number(rawEnd) : fileSize - 1
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(Readable.toWeb(stream) as any, {
status: 206,
headers: {
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
"Accept-Ranges": "bytes",
"Content-Length": chunkSize.toString(),
"Content-Type": mime,
"Cache-Control": "public, max-age=31536000, immutable",
},
})
}
const stream = fs.createReadStream(filePath)
return new NextResponse(Readable.toWeb(stream) as any, {
status: 200,
headers: {
"Content-Type": mime,
"Content-Length": fileSize.toString(),
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=31536000, immutable",
},
})
} catch (error) {
console.error("Failed to read media file", error)
return NextResponse.json({ error: "Unable to read file" }, { status: 500 })
}
}

View File

@@ -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}

View File

@@ -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 && (

View File

@@ -0,0 +1,87 @@
"use client"
import type { QuestionMedia as QuestionMediaType } from "@rahoot/common/types/game"
import clsx from "clsx"
import { useState } from "react"
type Props = {
media?: QuestionMediaType
alt: string
onPlayChange?: (_playing: boolean) => void
}
const QuestionMedia = ({ media, alt, onPlayChange }: Props) => {
const [zoomed, setZoomed] = useState(false)
if (!media) {
return null
}
const containerClass = "mx-auto flex w-full max-w-5xl justify-center"
switch (media.type) {
case "image":
return (
<>
<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 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":
return (
<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"
onPlay={() => onPlayChange?.(true)}
onPause={() => onPlayChange?.(false)}
onEnded={() => onPlayChange?.(false)}
/>
</div>
)
case "video":
return (
<div className={containerClass}>
<video
controls
crossOrigin="anonymous"
playsInline
src={media.url}
className="m-4 w-full max-w-5xl rounded-md shadow-lg"
preload="metadata"
onPlay={() => onPlayChange?.(true)}
onPause={() => onPlayChange?.(false)}
onEnded={() => onPlayChange?.(false)}
/>
</div>
)
default:
return null
}
}
export default QuestionMedia

View 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

View File

@@ -0,0 +1,812 @@
"use client"
import type { QuestionMedia, QuizzWithId } from "@rahoot/common/types/game"
import Button from "@rahoot/web/components/Button"
import Input from "@rahoot/web/components/Input"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import clsx from "clsx"
import { useCallback, useEffect, useMemo, useState } from "react"
import toast from "react-hot-toast"
type Props = {
quizzList: QuizzWithId[]
onBack: () => void
onListUpdate: (_quizz: QuizzWithId[]) => void
}
type EditableQuestion = QuizzWithId["questions"][number]
type MediaLibraryItem = {
fileName: string
url: string
size: number
mime: string
type: QuestionMedia["type"]
usedBy: {
quizzId: string
subject: string
questionIndex: number
question: string
}[]
}
const blankQuestion = (): EditableQuestion => ({
question: "",
answers: ["", ""],
solution: [0],
cooldown: 5,
time: 20,
})
const mediaTypes: QuestionMedia["type"][] = ["image", "audio", "video"]
const acceptByType: Record<QuestionMedia["type"], string> = {
image: "image/*",
audio: "audio/*",
video: "video/*",
}
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 QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
const { socket } = useSocket()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [draft, setDraft] = useState<QuizzWithId | null>(null)
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(false)
const [mediaLibrary, setMediaLibrary] = useState<MediaLibraryItem[]>([])
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)
setLoading(false)
})
useEvent("manager:quizzSaved", (quizz) => {
toast.success("Quiz saved")
setDraft(quizz)
setSelectedId(quizz.id)
setSaving(false)
refreshMediaLibrary()
})
useEvent("manager:quizzDeleted", (id) => {
toast.success("Quiz deleted")
if (selectedId === id) {
setSelectedId(null)
setDraft(null)
}
refreshMediaLibrary()
})
useEvent("manager:quizzList", (list) => {
onListUpdate(list)
})
useEvent("manager:errorMessage", (message) => {
toast.error(message)
setSaving(false)
setLoading(false)
})
const refreshMediaLibrary = useCallback(async () => {
setRefreshingLibrary(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 library")
}
setMediaLibrary(data.media || [])
} catch (error) {
console.error("Failed to fetch media library", error)
toast.error(
error instanceof Error ? error.message : "Failed to load media library",
)
} finally {
setRefreshingLibrary(false)
}
}, [])
useEffect(() => {
refreshMediaLibrary()
}, [refreshMediaLibrary])
const handleLoad = (id: string) => {
setSelectedId(id)
setLoading(true)
socket?.emit("manager:getQuizz", id)
}
const handleNew = () => {
setSelectedId(null)
setDraft({
id: "",
subject: "",
questions: [blankQuestion()],
})
}
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>,
) => {
if (!draft) return
const nextQuestions = [...draft.questions]
nextQuestions[index] = { ...nextQuestions[index], ...patch }
setDraft({ ...draft, questions: nextQuestions })
}
const updateAnswer = (qIndex: number, aIndex: number, value: string) => {
if (!draft) return
const nextQuestions = [...draft.questions]
const nextAnswers = [...nextQuestions[qIndex].answers]
nextAnswers[aIndex] = value
nextQuestions[qIndex] = { ...nextQuestions[qIndex], answers: nextAnswers }
setDraft({ ...draft, questions: nextQuestions })
}
const addAnswer = (qIndex: number) => {
if (!draft) return
const nextQuestions = [...draft.questions]
if (nextQuestions[qIndex].answers.length >= 4) {
return
}
nextQuestions[qIndex] = {
...nextQuestions[qIndex],
answers: [...nextQuestions[qIndex].answers, ""],
}
setDraft({ ...draft, questions: nextQuestions })
}
const removeAnswer = (qIndex: number, aIndex: number) => {
if (!draft) return
const nextQuestions = [...draft.questions]
const currentAnswers = [...nextQuestions[qIndex].answers]
if (currentAnswers.length <= 2) {
return
}
currentAnswers.splice(aIndex, 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,
solution: nextSolution,
}
setDraft({ ...draft, questions: nextQuestions })
}
const addQuestion = () => {
if (!draft) return
setDraft({ ...draft, questions: [...draft.questions, blankQuestion()] })
}
const removeQuestion = (index: number) => {
if (!draft || draft.questions.length <= 1) return
const nextQuestions = draft.questions.filter((_, i) => i !== index)
setDraft({ ...draft, questions: nextQuestions })
}
const setQuestionMedia = (qIndex: number, media?: QuestionMedia) => {
if (!draft) return
updateQuestion(qIndex, {
media,
image: media?.type === "image" ? media.url : undefined,
})
}
const getMediaFileName = (media?: QuestionMedia | null) => {
if (!media) return null
if (media.fileName) return media.fileName
if (media.url?.startsWith("/media/")) {
return decodeURIComponent(media.url.split("/").pop() || "")
}
return null
}
const getLibraryEntry = (media?: QuestionMedia | null) => {
const fileName = getMediaFileName(media)
if (!fileName) return null
return mediaLibrary.find((item) => item.fileName === fileName) || null
}
const handleMediaType = (qIndex: number, type: QuestionMedia["type"] | "") => {
if (!draft) return
const question = draft.questions[qIndex]
if (type === "") {
setQuestionMedia(qIndex, undefined)
return
}
const nextMedia =
question.media?.type === type
? { ...question.media, type }
: { type, url: "" }
setQuestionMedia(qIndex, nextMedia)
}
const handleMediaUrlChange = (qIndex: number, url: string) => {
if (!draft) return
const question = draft.questions[qIndex]
if (!question.media?.type) {
toast.error("Select a media type before setting a URL")
return
}
if (!url) {
setQuestionMedia(qIndex, undefined)
return
}
const nextMedia: QuestionMedia = {
type: question.media.type,
url,
}
if (question.media.fileName && url.includes(question.media.fileName)) {
nextMedia.fileName = question.media.fileName
}
setQuestionMedia(qIndex, nextMedia)
}
const clearQuestionMedia = (qIndex: number) => {
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]
if (!question.media?.type) {
toast.error("Select a media type before uploading")
return
}
setUploading((prev) => ({ ...prev, [qIndex]: true }))
try {
const formData = new FormData()
formData.append("file", file)
const res = await fetch("/api/media", {
method: "POST",
body: formData,
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || "Failed to upload media")
}
const uploaded = data.media as MediaLibraryItem
const type = uploaded.type
setQuestionMedia(qIndex, {
type,
url: uploaded.url,
fileName: uploaded.fileName,
})
toast.success("Media uploaded")
refreshMediaLibrary()
} catch (error) {
console.error("Upload failed", error)
toast.error(error instanceof Error ? error.message : "Upload failed")
} finally {
setUploading((prev) => ({ ...prev, [qIndex]: false }))
}
}
const handleDeleteMediaFile = async (qIndex: number) => {
if (!draft) return
const question = draft.questions[qIndex]
const fileName = getMediaFileName(question.media)
if (!fileName) {
toast.error("No stored file to delete")
return
}
setDeleting((prev) => ({ ...prev, [qIndex]: 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")
}
toast.success("File deleted")
clearQuestionMedia(qIndex)
refreshMediaLibrary()
} catch (error) {
console.error("Failed to delete file", error)
toast.error(error instanceof Error ? error.message : "Failed to delete file")
} finally {
setDeleting((prev) => ({ ...prev, [qIndex]: false }))
}
}
const handleSave = () => {
if (!draft) return
setSaving(true)
socket?.emit("manager:saveQuizz", {
id: draft.id || null,
quizz: {
subject: draft.subject,
questions: draft.questions,
},
})
}
const selectedLabel = useMemo(() => {
if (!selectedId) return "New quiz"
const found = quizzList.find((q) => q.id === selectedId)
return found ? `Editing: ${found.subject}` : `Editing: ${selectedId}`
}, [quizzList, selectedId])
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>
{selectedId && (
<Button className="bg-red-600" onClick={handleDeleteQuizz} disabled={saving}>
Delete quiz
</Button>
)}
</div>
<Button onClick={handleSave} disabled={saving || loading}>
{saving ? "Saving..." : "Save quiz"}
</Button>
</div>
<div className="flex flex-col gap-3 rounded-md border border-gray-200 p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-gray-600">
Existing quizzes:
</span>
{quizzList.map((quizz) => (
<button
key={quizz.id}
onClick={() => handleLoad(quizz.id)}
className={clsx(
"rounded-sm border px-3 py-1 text-sm font-semibold",
selectedId === quizz.id
? "border-primary text-primary"
: "border-gray-300",
)}
>
{quizz.subject}
</button>
))}
</div>
</div>
{!draft && (
<div className="rounded-md border border-dashed border-gray-300 p-6 text-center text-gray-600">
{loading ? "Loading quiz..." : "Select a quiz to edit or create a new one."}
</div>
)}
{draft && (
<div className="space-y-4">
<div className="rounded-md border border-gray-200 p-4">
<div className="mb-2 text-sm font-semibold text-gray-700">
{selectedLabel}
</div>
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-gray-600">Subject</span>
<Input
value={draft.subject}
onChange={(e) => setDraft({ ...draft, subject: e.target.value })}
placeholder="Quiz title"
/>
</label>
</div>
{draft.questions.map((question, qIndex) => {
const libraryEntry = getLibraryEntry(question.media)
const mediaFileName = getMediaFileName(question.media)
const isUploading = uploading[qIndex]
const isDeleting = deleting[qIndex]
return (
<div
key={qIndex}
className="rounded-md border border-gray-200 p-4 shadow-sm"
>
<div className="mb-3 flex items-center justify-between">
<div className="text-lg font-semibold text-gray-800">
Question {qIndex + 1}
</div>
<div className="flex gap-2">
<Button
className="bg-red-500"
onClick={() => removeQuestion(qIndex)}
disabled={draft.questions.length <= 1}
>
Remove
</Button>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-gray-600">Prompt</span>
<Input
value={question.question}
onChange={(e) =>
updateQuestion(qIndex, { question: e.target.value })
}
placeholder="Enter the question"
/>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-gray-600">
Cooldown (s)
</span>
<Input
type="number"
value={question.cooldown}
onChange={(e) =>
updateQuestion(qIndex, {
cooldown: Number(e.target.value || 0),
})
}
min={0}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-gray-600">
Answer time (s)
</span>
<Input
type="number"
value={question.time}
onChange={(e) =>
updateQuestion(qIndex, { time: Number(e.target.value || 0) })
}
min={5}
/>
</label>
</div>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-gray-600">
Media type
</span>
<select
className="rounded-sm border border-gray-300 p-2 font-semibold"
value={question.media?.type || ""}
onChange={(e) =>
handleMediaType(qIndex, e.target.value as QuestionMedia["type"] | "")
}
>
<option value="">None</option>
{mediaTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</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..."
: probing[qIndex]
? "Probing..."
: refreshingLibrary
? "Refreshing..."
: mediaFileName
? "Stored"
: "Not saved"}
</span>
</div>
<input
type="file"
accept={
question.media?.type ? acceptByType[question.media.type] : undefined
}
disabled={!question.media?.type || isUploading}
className="rounded-sm border border-dashed border-gray-300 p-2 text-sm"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
handleMediaUpload(qIndex, file)
e.target.value = ""
}
}}
/>
<p className="text-xs text-gray-500">
Files are stored locally and served from /media. Pick a type first.
</p>
{question.media && (
<div className="rounded-md border border-gray-200 bg-gray-50 p-2">
<div className="flex items-center justify-between text-sm font-semibold text-gray-700">
<span>
{mediaFileName || question.media.url || "No file yet"}
</span>
{libraryEntry && (
<span className="text-xs text-gray-500">
{formatBytes(libraryEntry.size)}
</span>
)}
</div>
<div className="text-xs text-gray-500">
{libraryEntry
? `Used in ${libraryEntry.usedBy.length} question${
libraryEntry.usedBy.length === 1 ? "" : "s"
}`
: question.media.url
? "External media URL"
: "Upload a file or paste a URL"}
</div>
</div>
)}
<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>
{question.media?.type !== "image" && question.media?.url && (
<div className="flex flex-wrap items-center gap-2">
<Button
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>
<Button
className="bg-red-500"
onClick={() => handleDeleteMediaFile(qIndex)}
disabled={!mediaFileName || isDeleting}
>
{isDeleting ? "Deleting..." : "Delete file"}
</Button>
</div>
</div>
</div>
<div className="mt-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-700">Answers</span>
<Button
className="bg-blue-600"
onClick={() => addAnswer(qIndex)}
disabled={question.answers.length >= 4}
>
Add answer
</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",
(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)
}
updateQuestion(qIndex, { solution: next })
}}
/>
<Input
className="flex-1"
value={answer}
onChange={(e) =>
updateAnswer(qIndex, aIndex, e.target.value)
}
placeholder={`Answer ${aIndex + 1}`}
/>
<button
className="rounded-sm px-2 py-1 text-sm font-semibold text-red-500"
onClick={() => removeAnswer(qIndex, aIndex)}
disabled={question.answers.length <= 2}
>
Remove
</button>
</div>
))}
</div>
</div>
</div>
)
})}
<div className="flex justify-center">
<Button className="bg-blue-600" onClick={addQuestion}>
Add question
</Button>
</div>
</div>
)}
</div>
)
}
export default QuizEditor

View File

@@ -7,9 +7,11 @@ import toast from "react-hot-toast"
type Props = {
quizzList: QuizzWithId[]
onSelect: (_id: string) => void
onManage?: () => void
onMedia?: () => void
}
const SelectQuizz = ({ quizzList, onSelect }: Props) => {
const SelectQuizz = ({ quizzList, onSelect, onManage, onMedia }: Props) => {
const [selected, setSelected] = useState<string | null>(null)
const handleSelect = (id: string) => () => {
@@ -32,8 +34,28 @@ const SelectQuizz = ({ quizzList, onSelect }: Props) => {
return (
<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>
<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">
<h1 className="mb-2 text-2xl font-bold">Select a quizz</h1>
<div className="w-full space-y-2">
{quizzList.map((quizz) => (
<button

View File

@@ -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}`)
})

View File

@@ -2,6 +2,7 @@
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
import AnswerButton from "@rahoot/web/components/AnswerButton"
import QuestionMedia from "@rahoot/web/components/game/QuestionMedia"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player"
import {
@@ -20,39 +21,73 @@ type Props = {
}
const Answers = ({
data: { question, answers, image, 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,
})
const [playMusic, { stop: stopMusic }] = useSound(SFX_ANSWERS_MUSIC, {
volume: 0.2,
interrupt: true,
loop: true,
})
const [playMusic, { stop: stopMusic, sound: answersMusic }] = useSound(
SFX_ANSWERS_MUSIC,
{
volume: 0.2,
interrupt: true,
loop: true,
},
)
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()
@@ -61,29 +96,52 @@ const Answers = ({
}
}, [playMusic])
useEffect(() => {
if (!answersMusic) {
return
}
answersMusic.volume(isMediaPlaying ? 0.05 : 0.2)
}, [answersMusic, isMediaPlaying])
useEvent("game:cooldown", (sec) => {
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>
{Boolean(image) && (
<img
alt={question}
src={image}
className="m-4 h-full max-h-[400px] min-h-[200px] w-auto rounded-md"
/>
{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)}
alt={question}
onPlayChange={(playing) => setIsMediaPlaying(playing)}
/>
</div>
<div>
@@ -91,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>
@@ -107,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>
)

View File

@@ -1,21 +1,35 @@
"use client"
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 = {
data: CommonStatusDataMap["SHOW_QUESTION"]
}
const Question = ({ data: { question, image, cooldown } }: 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">
@@ -23,18 +37,22 @@ const Question = ({ data: { question, image, cooldown } }: Props) => {
{question}
</h2>
{Boolean(image) && (
<img
alt={question}
src={image}
className="m-4 h-full max-h-[400px] min-h-[200px] w-auto rounded-md"
/>
)}
<QuestionMedia
media={media || (image ? { type: "image", url: image } : undefined)}
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>
)
}

View File

@@ -2,6 +2,7 @@
import { ManagerStatusDataMap } from "@rahoot/common/types/game/status"
import AnswerButton from "@rahoot/web/components/AnswerButton"
import QuestionMedia from "@rahoot/web/components/game/QuestionMedia"
import {
ANSWERS_COLORS,
ANSWERS_ICONS,
@@ -18,24 +19,28 @@ type Props = {
}
const Responses = ({
data: { question, answers, responses, correct },
data: { question, answers, responses, correct, image, media },
}: Props) => {
const [percentages, setPercentages] = useState<Record<string, string>>({})
const [isMusicPlaying, setIsMusicPlaying] = useState(false)
const [isMediaPlaying, setIsMediaPlaying] = useState(false)
const [sfxResults] = useSound(SFX_RESULTS_SOUND, {
volume: 0.2,
})
const [playMusic, { stop: stopMusic }] = useSound(SFX_ANSWERS_MUSIC, {
volume: 0.2,
onplay: () => {
setIsMusicPlaying(true)
const [playMusic, { stop: stopMusic, sound: answersMusic }] = useSound(
SFX_ANSWERS_MUSIC,
{
volume: 0.2,
onplay: () => {
setIsMusicPlaying(true)
},
onend: () => {
setIsMusicPlaying(false)
},
},
onend: () => {
setIsMusicPlaying(false)
},
})
)
useEffect(() => {
stopMusic()
@@ -50,10 +55,22 @@ const Responses = ({
}
}, [isMusicPlaying, playMusic])
useEffect(() => {
if (!answersMusic) {
return
}
answersMusic.volume(isMediaPlaying ? 0.05 : 0.2)
}, [answersMusic, isMediaPlaying])
useEffect(() => {
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">
@@ -61,6 +78,12 @@ const Responses = ({
{question}
</h2>
<QuestionMedia
media={media || (image ? { type: "image", url: image } : undefined)}
alt={question}
onPlayChange={(playing) => setIsMediaPlaying(playing)}
/>
<div
className={`mt-8 grid h-40 w-full max-w-3xl gap-4 px-2`}
style={{ gridTemplateColumns: `repeat(${answers.length}, 1fr)` }}
@@ -88,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]}
>

View File

@@ -37,9 +37,26 @@ const SocketContext = createContext<SocketContextValue>({
})
const getSocketServer = async () => {
const res = await ky.get("/socket").json<{ url: string }>()
try {
const res = await ky.get("/socket").json<{ url: string }>()
if (res.url) return res.url
} catch (error) {
console.error("Failed to fetch socket url, using fallback", error)
}
return res.url
if (typeof window !== "undefined") {
const { protocol, hostname } = window.location
const isHttps = protocol === "https:"
const port =
window.location.port && window.location.port !== "3000"
? window.location.port
: "3001"
const scheme = isHttps ? "https:" : "http:"
return `${scheme}//${hostname}:${port}`
}
return "http://localhost:3001"
}
const getClientId = (): string => {
@@ -75,12 +92,20 @@ export const SocketProvider = ({ children }: { children: React.ReactNode }) => {
try {
const socketUrl = await getSocketServer()
const isHttps = socketUrl.startsWith("https")
s = io(socketUrl, {
transports: ["websocket"],
transports: ["websocket", "polling"],
autoConnect: false,
withCredentials: false,
forceNew: true,
secure: isHttps,
auth: {
clientId,
},
reconnection: true,
reconnectionAttempts: 5,
timeout: 12000,
})
setSocket(s)
@@ -94,7 +119,10 @@ export const SocketProvider = ({ children }: { children: React.ReactNode }) => {
})
s.on("connect_error", (err) => {
console.error("Connection error:", err.message)
console.error("Connection error:", err.message, {
url: socketUrl,
transport: s?.io?.opts?.transports,
})
})
} catch (error) {
console.error("Failed to initialize socket:", error)

View File

@@ -0,0 +1,257 @@
import type { QuestionMedia, QuizzWithId } from "@rahoot/common/types/game"
import Config from "@rahoot/socket/services/config"
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
size: number
mime: string
type: QuestionMedia["type"]
usedBy: {
quizzId: string
subject: string
questionIndex: number
question: string
}[]
}
const ensureMediaFolder = () => {
Config.ensureBaseFolders()
const folder = Config.getMediaPath()
if (!fs.existsSync(folder)) {
fs.mkdirSync(folder, { recursive: true })
}
return folder
}
const inferMimeFromName = (fileName: string) => {
const ext = path.extname(fileName).toLowerCase()
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",
}
return map[ext] || "application/octet-stream"
}
const inferMediaType = (mime: string): QuestionMedia["type"] | null => {
if (mime.startsWith("image/")) return "image"
if (mime.startsWith("audio/")) return "audio"
if (mime.startsWith("video/")) return "video"
return null
}
const sanitizeFileName = (name: string) => {
const safeName = name.replace(/[^a-zA-Z0-9._-]/g, "_")
return safeName || `media-${Date.now()}`
}
const resolveStoredFileName = (fileName: string) => {
const safeName = path.basename(fileName)
if (safeName !== fileName) {
throw new Error("Invalid file name")
}
return safeName
}
const usageIndex = (quizzList: QuizzWithId[]) => {
const usage = new Map<string, StoredMedia["usedBy"]>()
const recordUsage = (
fileName: string | null,
quizz: QuizzWithId,
questionIndex: number,
questionTitle: string,
) => {
if (!fileName) return
try {
const safeName = resolveStoredFileName(fileName)
const entries = usage.get(safeName) || []
entries.push({
quizzId: quizz.id,
subject: quizz.subject,
questionIndex,
question: questionTitle,
})
usage.set(safeName, entries)
} catch (error) {
console.warn("Skipped invalid media reference", { fileName, error })
}
}
quizzList.forEach((quizz) => {
quizz.questions.forEach((question, idx) => {
const mediaFile = (() => {
if (question.media?.fileName) return question.media.fileName
if (question.media?.url?.startsWith("/media/")) {
try {
return resolveStoredFileName(
decodeURIComponent(question.media.url.split("/").pop() || ""),
)
} catch (error) {
console.warn("Skipped invalid media url reference", {
url: question.media.url,
error,
})
return null
}
}
return null
})()
const imageFile = (() => {
if (!question.image?.startsWith("/media/")) return null
try {
return resolveStoredFileName(
decodeURIComponent(question.image.split("/").pop() || ""),
)
} catch (error) {
console.warn("Skipped invalid image url reference", {
url: question.image,
error,
})
return null
}
})()
recordUsage(mediaFile, quizz, idx, question.question)
recordUsage(imageFile, quizz, idx, question.question)
})
})
return usage
}
export const listStoredMedia = async (): Promise<StoredMedia[]> => {
const folder = ensureMediaFolder()
const files = await fsp.readdir(folder)
const quizz = Config.quizz()
const usage = usageIndex(quizz)
const entries = await Promise.all(
files.map(async (fileName) => {
const stats = await fsp.stat(path.join(folder, fileName))
const mime = inferMimeFromName(fileName)
const type = inferMediaType(mime) || "video"
return {
fileName,
url: `/media/${encodeURIComponent(fileName)}`,
size: stats.size,
mime,
type,
usedBy: usage.get(fileName) || [],
}
}),
)
// Keep a stable order for repeatable responses
return entries.sort((a, b) => a.fileName.localeCompare(b.fileName))
}
export const storeMediaFile = async (file: File): Promise<StoredMedia> => {
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
if (buffer.byteLength > MAX_UPLOAD_SIZE) {
throw new Error(
`File is too large. Max ${Math.round(MAX_UPLOAD_SIZE / 1024 / 1024)}MB.`,
)
}
const targetFolder = ensureMediaFolder()
const incomingMime = file.type || "application/octet-stream"
const mediaType = inferMediaType(incomingMime)
if (!mediaType) {
throw new Error("Unsupported media type")
}
const incomingName = file.name || `${mediaType}-upload`
const safeName = sanitizeFileName(incomingName)
const ext = path.extname(safeName) || `.${incomingMime.split("/")[1] || "bin"}`
const baseName = path.basename(safeName, ext)
let finalName = `${baseName}${ext}`
let finalPath = path.join(targetFolder, finalName)
let counter = 1
while (fs.existsSync(finalPath)) {
finalName = `${baseName}-${counter}${ext}`
finalPath = path.join(targetFolder, finalName)
counter += 1
}
await fsp.writeFile(finalPath, buffer)
const mime = incomingMime || inferMimeFromName(finalName)
return {
fileName: finalName,
url: `/media/${encodeURIComponent(finalName)}`,
size: buffer.byteLength,
mime,
type: mediaType,
usedBy: [],
}
}
export const deleteMediaFile = async (fileName: string) => {
const folder = ensureMediaFolder()
const safeName = resolveStoredFileName(fileName)
const filePath = path.join(folder, safeName)
if (!fs.existsSync(filePath)) {
throw new Error("File not found")
}
const usage = usageIndex(Config.quizz())
const usedBy = usage.get(safeName) || []
if (usedBy.length > 0) {
const details = usedBy
.map(
(entry) =>
`${entry.subject || entry.quizzId} (question ${entry.questionIndex + 1})`,
)
.join(", ")
throw new Error(`File is still used by: ${details}`)
}
await fsp.unlink(filePath)
}
export const mimeForStoredFile = (fileName: string) => inferMimeFromName(fileName)

View File

@@ -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),
}))

View File

@@ -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)
},
}))

View File

@@ -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
View File

@@ -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: