mirror of
https://github.com/randyjc/Rahoot.git
synced 2026-09-08 12:13:15 +02:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c284985fa | ||
|
|
ae37df6643 | ||
|
|
8a25192034 | ||
|
|
84b269d2d1 | ||
|
|
760fc93c1b | ||
|
|
ab7ddfed4b | ||
|
|
3ac9d5ac39 | ||
|
|
a4fa783ed2 | ||
|
|
6b1b976790 | ||
|
|
fb2da91394 | ||
|
|
a23299a45e | ||
|
|
13c1e9c3f6 | ||
|
|
e4342bbdac | ||
|
|
61b47fa5d5 | ||
|
|
efbea1801c | ||
|
|
dba5024207 | ||
|
|
684a6a3c12 | ||
|
|
9d64f7f0b4 | ||
|
|
1679e68691 | ||
|
|
6c16dd146a | ||
|
|
f748d6ec3f | ||
|
|
a572eb35cd | ||
|
|
7bfb138f99 | ||
|
|
9a9ad640c0 | ||
|
|
3d247513ce | ||
|
|
497dd2ea4c |
@@ -53,19 +53,16 @@ docker run -d \
|
|||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-p 3001:3001 \
|
-p 3001:3001 \
|
||||||
-v ./config:/app/config \
|
-v ./config:/app/config \
|
||||||
|
-e REDIS_URL=redis://user:pass@redis:6379 \
|
||||||
|
-e MEDIA_MAX_UPLOAD_MB=200 \
|
||||||
-e WEB_ORIGIN=http://localhost:3000 \
|
-e WEB_ORIGIN=http://localhost:3000 \
|
||||||
-e SOCKET_URL=http://localhost:3001 \
|
-e SOCKET_URL=http://localhost:3001 \
|
||||||
ralex91/rahoot:latest
|
ralex91/rahoot:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
**Configuration Volume:**
|
**Configuration & Media Volume:**
|
||||||
The `-v ./config:/app/config` option mounts a local `config` folder to persist your game settings and quizzes. This allows you to:
|
- `-v ./config:/app/config` mounts a local `config` folder to persist settings, quizzes, and uploaded media (`config/quizz`, `config/media`). This keeps your data across redeploys and lets you back it up easily.
|
||||||
|
- The folder is auto-created on first run with an example quiz.
|
||||||
- Edit your configuration files directly on your host machine
|
|
||||||
- Keep your settings when updating the container
|
|
||||||
- Easily backup your quizzes and game configuration
|
|
||||||
|
|
||||||
The folder will be created automatically on first run with an example quiz to get you started.
|
|
||||||
|
|
||||||
The application will be available at:
|
The application will be available at:
|
||||||
|
|
||||||
@@ -150,16 +147,30 @@ Quiz Options:
|
|||||||
- `question`: The question text
|
- `question`: The question text
|
||||||
- `answers`: Array of possible answers (2-4 options)
|
- `answers`: Array of possible answers (2-4 options)
|
||||||
- `image`: Optional URL for question image (legacy; use `media` for new content)
|
- `image`: Optional URL for question image (legacy; use `media` for new content)
|
||||||
- `media`: Optional media attachment `{ "type": "image" | "audio" | "video" | "youtube", "url": "<link>" }`. Examples:
|
- `media`: Optional media attachment `{ "type": "image" | "audio" | "video", "url": "<link>" }`. Examples:
|
||||||
- `{"type":"audio","url":"https://.../clip.mp3"}`
|
- `{"type":"audio","url":"https://.../clip.mp3"}`
|
||||||
- `{"type":"video","url":"https://.../clip.mp4"}`
|
- `{"type":"video","url":"https://.../clip.mp4"}`
|
||||||
- `{"type":"youtube","url":"https://youtu.be/dQw4w9WgXcQ"}`
|
|
||||||
- `solution`: Index of correct answer (0-based)
|
- `solution`: Index of correct answer (0-based)
|
||||||
- `cooldown`: Time in seconds before showing the question
|
- `cooldown`: Time in seconds before showing the question
|
||||||
- `time`: Time in seconds allowed to answer
|
- `time`: Time in seconds allowed to answer
|
||||||
|
|
||||||
Tip: You can now create and edit quizzes directly from the Manager UI (login at `/manager` and click “Manage”).
|
Tip: You can now create and edit quizzes directly from the Manager UI (login at `/manager` and click “Manage”).
|
||||||
|
|
||||||
|
### Manager Features
|
||||||
|
- Upload image/audio/video directly in the quiz editor (stored under `config/media`).
|
||||||
|
- Manual “Set timing from media” to align cooldown/answer time with clip length.
|
||||||
|
- Media library view: see all uploads, where they’re used, and delete unused files.
|
||||||
|
- Delete quizzes from the editor.
|
||||||
|
- Pause/Resume/Break/Skip question intro and answer timers; End Game button to reset everyone.
|
||||||
|
- Player list in manager view showing connected/disconnected players (persists across reconnects); resume a game from where it left off.
|
||||||
|
- 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
|
## 🎮 How to Play
|
||||||
|
|
||||||
1. Access the manager interface at http://localhost:3000/manager
|
1. Access the manager interface at http://localhost:3000/manager
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
],
|
],
|
||||||
"media": {
|
"media": {
|
||||||
"type": "audio",
|
"type": "audio",
|
||||||
"url": "https://upload.wikimedia.org/wikipedia/commons/transcoded/4/4c/Beethoven_Moonlight_1st_movement.ogg/Beethoven_Moonlight_1st_movement.ogg.mp3"
|
"url": "https://file-examples.com/storage/fee95f49ad692e9489b0fab/2017/11/file_example_WAV_1MG.wav"
|
||||||
},
|
},
|
||||||
"solution": 1,
|
"solution": 1,
|
||||||
"cooldown": 5,
|
"cooldown": 5,
|
||||||
@@ -32,22 +32,6 @@
|
|||||||
"solution": 3,
|
"solution": 3,
|
||||||
"cooldown": 5,
|
"cooldown": 5,
|
||||||
"time": 60
|
"time": 60
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What kind of animal is featured here?",
|
|
||||||
"answers": [
|
|
||||||
"Dolphin",
|
|
||||||
"Panda",
|
|
||||||
"Horse",
|
|
||||||
"Penguin"
|
|
||||||
],
|
|
||||||
"media": {
|
|
||||||
"type": "youtube",
|
|
||||||
"url": "https://www.youtube.com/watch?v=2k1qW3D0q6c"
|
|
||||||
},
|
|
||||||
"solution": 2,
|
|
||||||
"cooldown": 5,
|
|
||||||
"time": 40
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -26,10 +26,9 @@ export type Quizz = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type QuestionMedia =
|
export type QuestionMedia =
|
||||||
| { type: "image"; url: string }
|
| { type: "image"; url: string; fileName?: string }
|
||||||
| { type: "audio"; url: string }
|
| { type: "audio"; url: string; fileName?: string }
|
||||||
| { type: "video"; url: string }
|
| { type: "video"; url: string; fileName?: string }
|
||||||
| { type: "youtube"; url: string }
|
|
||||||
|
|
||||||
export type QuizzWithId = Quizz & { id: string }
|
export type QuizzWithId = Quizz & { id: string }
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Server as ServerIO, Socket as SocketIO } from "socket.io"
|
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"
|
import { Status, StatusDataMap } from "./status"
|
||||||
|
|
||||||
export type Server = ServerIO<ClientToServerEvents, ServerToClientEvents>
|
export type Server = ServerIO<ClientToServerEvents, ServerToClientEvents>
|
||||||
@@ -31,9 +31,11 @@ export interface ServerToClientEvents {
|
|||||||
"game:errorMessage": (_message: string) => void
|
"game:errorMessage": (_message: string) => void
|
||||||
"game:startCooldown": () => void
|
"game:startCooldown": () => void
|
||||||
"game:cooldown": (_count: number) => void
|
"game:cooldown": (_count: number) => void
|
||||||
|
"game:cooldownPause": (_paused: boolean) => void
|
||||||
"game:reset": (_message: string) => void
|
"game:reset": (_message: string) => void
|
||||||
"game:updateQuestion": (_data: { current: number; total: number }) => void
|
"game:updateQuestion": (_data: { current: number; total: number }) => void
|
||||||
"game:playerAnswer": (_count: number) => void
|
"game:playerAnswer": (_count: number) => void
|
||||||
|
"game:break": (_active: boolean) => void
|
||||||
|
|
||||||
// Player events
|
// Player events
|
||||||
"player:successReconnect": (_data: {
|
"player:successReconnect": (_data: {
|
||||||
@@ -59,8 +61,13 @@ export interface ServerToClientEvents {
|
|||||||
}) => void
|
}) => void
|
||||||
"manager:newPlayer": (_player: Player) => void
|
"manager:newPlayer": (_player: Player) => void
|
||||||
"manager:removePlayer": (_playerId: string) => void
|
"manager:removePlayer": (_playerId: string) => void
|
||||||
|
"manager:players": (_players: Player[]) => void
|
||||||
"manager:errorMessage": (_message: string) => void
|
"manager:errorMessage": (_message: string) => void
|
||||||
"manager:playerKicked": (_playerId: string) => void
|
"manager:playerKicked": (_playerId: string) => void
|
||||||
|
"manager:quizzLoaded": (_quizz: QuizzWithId) => void
|
||||||
|
"manager:quizzSaved": (_quizz: QuizzWithId) => void
|
||||||
|
"manager:quizzDeleted": (_id: string) => void
|
||||||
|
"manager:break": (_active: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientToServerEvents {
|
export interface ClientToServerEvents {
|
||||||
@@ -71,8 +78,16 @@ export interface ClientToServerEvents {
|
|||||||
"manager:kickPlayer": (_message: { gameId: string; playerId: string }) => void
|
"manager:kickPlayer": (_message: { gameId: string; playerId: string }) => void
|
||||||
"manager:startGame": (_message: MessageGameId) => void
|
"manager:startGame": (_message: MessageGameId) => void
|
||||||
"manager:abortQuiz": (_message: MessageGameId) => void
|
"manager:abortQuiz": (_message: MessageGameId) => void
|
||||||
|
"manager:pauseCooldown": (_message: MessageGameId) => void
|
||||||
|
"manager:resumeCooldown": (_message: MessageGameId) => void
|
||||||
|
"manager:setBreak": (_message: { gameId?: string; active: boolean }) => void
|
||||||
|
"manager:endGame": (_message: MessageGameId) => void
|
||||||
|
"manager:skipQuestionIntro": (_message: MessageGameId) => void
|
||||||
"manager:nextQuestion": (_message: MessageGameId) => void
|
"manager:nextQuestion": (_message: MessageGameId) => void
|
||||||
|
"manager:deleteQuizz": (_message: { id: string }) => void
|
||||||
"manager:showLeaderboard": (_message: MessageGameId) => void
|
"manager:showLeaderboard": (_message: MessageGameId) => void
|
||||||
|
"manager:getQuizz": (_quizzId: string) => void
|
||||||
|
"manager:saveQuizz": (_payload: { id: string | null; quizz: Quizz }) => void
|
||||||
|
|
||||||
// Player actions
|
// Player actions
|
||||||
"player:join": (_inviteCode: string) => void
|
"player:join": (_inviteCode: string) => void
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@rahoot/common": "workspace:*",
|
"@rahoot/common": "workspace:*",
|
||||||
"@t3-oss/env-core": "^0.13.8",
|
"@t3-oss/env-core": "^0.13.8",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
|
"redis": "^4.6.13",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import env from "@rahoot/socket/env"
|
|||||||
import Config from "@rahoot/socket/services/config"
|
import Config from "@rahoot/socket/services/config"
|
||||||
import Game from "@rahoot/socket/services/game"
|
import Game from "@rahoot/socket/services/game"
|
||||||
import Registry from "@rahoot/socket/services/registry"
|
import Registry from "@rahoot/socket/services/registry"
|
||||||
|
import { loadSnapshot } from "@rahoot/socket/services/persistence"
|
||||||
import { withGame } from "@rahoot/socket/utils/game"
|
import { withGame } from "@rahoot/socket/utils/game"
|
||||||
import { Server as ServerIO } from "socket.io"
|
import { Server as ServerIO } from "socket.io"
|
||||||
|
|
||||||
@@ -34,6 +35,24 @@ io.on("connection", (socket) => {
|
|||||||
`A user connected: socketId: ${socket.id}, clientId: ${socket.handshake.auth.clientId}`
|
`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 }) => {
|
socket.on("player:reconnect", ({ gameId }) => {
|
||||||
const game = registry.getPlayerGame(gameId, socket.handshake.auth.clientId)
|
const game = registry.getPlayerGame(gameId, socket.handshake.auth.clientId)
|
||||||
|
|
||||||
@@ -43,11 +62,22 @@ io.on("connection", (socket) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureGame(gameId).then((restored) => {
|
||||||
|
if (restored) {
|
||||||
|
restored.reconnect(socket)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
socket.emit("game:reset", "Game not found")
|
socket.emit("game:reset", "Game not found")
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
socket.on("manager:reconnect", ({ gameId }) => {
|
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) {
|
if (game) {
|
||||||
game.reconnect(socket)
|
game.reconnect(socket)
|
||||||
@@ -55,8 +85,16 @@ io.on("connection", (socket) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureGame(gameId).then((restored) => {
|
||||||
|
if (restored) {
|
||||||
|
restored.reconnect(socket)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
socket.emit("game:reset", "Game expired")
|
socket.emit("game:reset", "Game expired")
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
socket.on("manager:auth", (password) => {
|
socket.on("manager:auth", (password) => {
|
||||||
try {
|
try {
|
||||||
@@ -111,6 +149,27 @@ io.on("connection", (socket) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
socket.on("manager:deleteQuizz", ({ id }) => {
|
||||||
|
if (!id) {
|
||||||
|
socket.emit("manager:errorMessage", "Invalid quizz id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deleted = Config.deleteQuizz(id)
|
||||||
|
if (!deleted) {
|
||||||
|
socket.emit("manager:errorMessage", "Quizz not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit("manager:quizzDeleted", id)
|
||||||
|
socket.emit("manager:quizzList", Config.quizz())
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete quizz", error)
|
||||||
|
socket.emit("manager:errorMessage", "Failed to delete quizz")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
socket.on("game:create", (quizzId) => {
|
socket.on("game:create", (quizzId) => {
|
||||||
const quizzList = Config.quizz()
|
const quizzList = Config.quizz()
|
||||||
const quizz = quizzList.find((q) => q.id === quizzId)
|
const quizz = quizzList.find((q) => q.id === quizzId)
|
||||||
@@ -167,10 +226,30 @@ io.on("connection", (socket) => {
|
|||||||
withGame(gameId, socket, (game) => game.abortRound(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:setBreak", ({ gameId, active }) =>
|
||||||
|
withGame(gameId, socket, (game) => game.setBreak(socket, active))
|
||||||
|
)
|
||||||
|
|
||||||
|
socket.on("manager:endGame", ({ gameId }) =>
|
||||||
|
withGame(gameId, socket, (game) => game.endGame(socket, registry))
|
||||||
|
)
|
||||||
|
|
||||||
socket.on("manager:nextQuestion", ({ gameId }) =>
|
socket.on("manager:nextQuestion", ({ gameId }) =>
|
||||||
withGame(gameId, socket, (game) => game.nextRound(socket))
|
withGame(gameId, socket, (game) => game.nextRound(socket))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
socket.on("manager:skipQuestionIntro", ({ gameId }) =>
|
||||||
|
withGame(gameId, socket, (game) => game.skipQuestionIntro(socket))
|
||||||
|
)
|
||||||
|
|
||||||
socket.on("manager:showLeaderboard", ({ gameId }) =>
|
socket.on("manager:showLeaderboard", ({ gameId }) =>
|
||||||
withGame(gameId, socket, (game) => game.showLeaderboard())
|
withGame(gameId, socket, (game) => game.showLeaderboard())
|
||||||
)
|
)
|
||||||
@@ -206,19 +285,9 @@ io.on("connection", (socket) => {
|
|||||||
return
|
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
|
player.connected = false
|
||||||
io.to(game.gameId).emit("game:totalPlayers", game.players.length)
|
io.to(game.gameId).emit("game:totalPlayers", game.players.length)
|
||||||
|
io.to(game.manager.id).emit("manager:players", game.players)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const getPath = (path: string = "") =>
|
|||||||
? resolve(inContainerPath, path)
|
? resolve(inContainerPath, path)
|
||||||
: resolve(process.cwd(), "../../config", path)
|
: resolve(process.cwd(), "../../config", path)
|
||||||
|
|
||||||
|
export const getConfigPath = (path: string = "") => getPath(path)
|
||||||
|
|
||||||
class Config {
|
class Config {
|
||||||
static ensureBaseFolders() {
|
static ensureBaseFolders() {
|
||||||
const isConfigFolderExists = fs.existsSync(getPath())
|
const isConfigFolderExists = fs.existsSync(getPath())
|
||||||
@@ -29,6 +31,28 @@ class Config {
|
|||||||
if (!isQuizzExists) {
|
if (!isQuizzExists) {
|
||||||
fs.mkdirSync(getPath("quizz"))
|
fs.mkdirSync(getPath("quizz"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMediaExists = fs.existsSync(getPath("media"))
|
||||||
|
|
||||||
|
if (!isMediaExists) {
|
||||||
|
fs.mkdirSync(getPath("media"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const isThemeExists = fs.existsSync(getPath("theme.json"))
|
||||||
|
|
||||||
|
if (!isThemeExists) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
getPath("theme.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
brandName: "Rahoot",
|
||||||
|
backgroundUrl: null,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static init() {
|
static init() {
|
||||||
@@ -109,19 +133,8 @@ class Config {
|
|||||||
"Golden Gate Bridge",
|
"Golden Gate Bridge",
|
||||||
],
|
],
|
||||||
media: {
|
media: {
|
||||||
type: "youtube",
|
type: "video",
|
||||||
url: "https://www.youtube.com/watch?v=jNQXAC9IVRw",
|
url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4",
|
||||||
},
|
|
||||||
solution: 3,
|
|
||||||
cooldown: 5,
|
|
||||||
time: 60,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "What kind of animal is featured here?",
|
|
||||||
answers: ["Dolphin", "Panda", "Horse", "Penguin"],
|
|
||||||
media: {
|
|
||||||
type: "youtube",
|
|
||||||
url: "https://www.youtube.com/watch?v=2k1qW3D0q6c",
|
|
||||||
},
|
},
|
||||||
solution: 2,
|
solution: 2,
|
||||||
cooldown: 5,
|
cooldown: 5,
|
||||||
@@ -154,6 +167,17 @@ class Config {
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static theme() {
|
||||||
|
this.ensureBaseFolders()
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(getPath("theme.json"), "utf-8")
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to read theme config:", error)
|
||||||
|
return { brandName: "Rahoot", backgroundUrl: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static quizz() {
|
static quizz() {
|
||||||
this.ensureBaseFolders()
|
this.ensureBaseFolders()
|
||||||
|
|
||||||
@@ -216,6 +240,35 @@ class Config {
|
|||||||
|
|
||||||
return this.getQuizz(finalId)
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
static saveTheme(theme: { brandName?: string; backgroundUrl?: string | null }) {
|
||||||
|
this.ensureBaseFolders()
|
||||||
|
const next = {
|
||||||
|
brandName: theme.brandName || "Rahoot",
|
||||||
|
backgroundUrl: theme.backgroundUrl ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(getPath("theme.json"), JSON.stringify(next, null, 2))
|
||||||
|
return next
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Config
|
export default Config
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Answer, Player, Quizz } from "@rahoot/common/types/game"
|
|||||||
import { Server, Socket } from "@rahoot/common/types/game/socket"
|
import { Server, Socket } from "@rahoot/common/types/game/socket"
|
||||||
import { Status, STATUS, StatusDataMap } from "@rahoot/common/types/game/status"
|
import { Status, STATUS, StatusDataMap } from "@rahoot/common/types/game/status"
|
||||||
import Registry from "@rahoot/socket/services/registry"
|
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 { createInviteCode, timeToPoint } from "@rahoot/socket/utils/game"
|
||||||
import sleep from "@rahoot/socket/utils/sleep"
|
import sleep from "@rahoot/socket/utils/sleep"
|
||||||
import { v4 as uuid } from "uuid"
|
import { v4 as uuid } from "uuid"
|
||||||
@@ -40,8 +41,12 @@ class Game {
|
|||||||
|
|
||||||
cooldown: {
|
cooldown: {
|
||||||
active: boolean
|
active: boolean
|
||||||
ms: number
|
paused: boolean
|
||||||
|
remaining: number
|
||||||
|
timer: NodeJS.Timeout | null
|
||||||
|
resolve: (() => void) | null
|
||||||
}
|
}
|
||||||
|
breakActive: boolean
|
||||||
|
|
||||||
constructor(io: Server, socket: Socket, quizz: Quizz) {
|
constructor(io: Server, socket: Socket, quizz: Quizz) {
|
||||||
if (!io) {
|
if (!io) {
|
||||||
@@ -52,8 +57,8 @@ class Game {
|
|||||||
this.gameId = uuid()
|
this.gameId = uuid()
|
||||||
this.manager = {
|
this.manager = {
|
||||||
id: "",
|
id: "",
|
||||||
clientId: "",
|
clientId: socket.handshake.auth.clientId,
|
||||||
connected: false,
|
connected: true,
|
||||||
}
|
}
|
||||||
this.inviteCode = ""
|
this.inviteCode = ""
|
||||||
this.started = false
|
this.started = false
|
||||||
@@ -75,16 +80,17 @@ class Game {
|
|||||||
|
|
||||||
this.cooldown = {
|
this.cooldown = {
|
||||||
active: false,
|
active: false,
|
||||||
ms: 0,
|
paused: false,
|
||||||
|
remaining: 0,
|
||||||
|
timer: null,
|
||||||
|
resolve: null,
|
||||||
}
|
}
|
||||||
|
this.breakActive = false
|
||||||
|
|
||||||
const roomInvite = createInviteCode()
|
const roomInvite = createInviteCode()
|
||||||
this.inviteCode = roomInvite
|
this.inviteCode = roomInvite
|
||||||
this.manager = {
|
this.manager.id = socket.id
|
||||||
id: socket.id,
|
|
||||||
clientId: socket.handshake.auth.clientId,
|
|
||||||
connected: true,
|
|
||||||
}
|
|
||||||
this.quizz = quizz
|
this.quizz = quizz
|
||||||
|
|
||||||
socket.join(this.gameId)
|
socket.join(this.gameId)
|
||||||
@@ -96,12 +102,57 @@ class Game {
|
|||||||
console.log(
|
console.log(
|
||||||
`New game created: ${roomInvite} subject: ${this.quizz.subject}`
|
`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,
|
||||||
|
}))
|
||||||
|
game.round = snapshot.round || {
|
||||||
|
playersAnswers: [],
|
||||||
|
currentQuestion: 0,
|
||||||
|
startTime: 0,
|
||||||
|
}
|
||||||
|
game.cooldown = {
|
||||||
|
active: snapshot.cooldown?.active || false,
|
||||||
|
paused: snapshot.cooldown?.paused || false,
|
||||||
|
remaining: snapshot.cooldown?.remaining || 0,
|
||||||
|
timer: null,
|
||||||
|
resolve: null,
|
||||||
|
}
|
||||||
|
game.breakActive = snapshot.breakActive || false
|
||||||
|
|
||||||
|
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]) {
|
broadcastStatus<T extends Status>(status: T, data: StatusDataMap[T]) {
|
||||||
const statusData = { name: status, data }
|
const statusData = { name: status, data }
|
||||||
this.lastBroadcastStatus = statusData
|
this.lastBroadcastStatus = statusData
|
||||||
this.io.to(this.gameId).emit("game:status", statusData)
|
this.io.to(this.gameId).emit("game:status", statusData)
|
||||||
|
this.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
sendStatus<T extends Status>(
|
sendStatus<T extends Status>(
|
||||||
@@ -118,16 +169,66 @@ class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.io.to(target).emit("game:status", statusData)
|
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,
|
||||||
|
},
|
||||||
|
breakActive: this.breakActive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
join(socket: Socket, username: string) {
|
||||||
const isAlreadyConnected = this.players.find(
|
const existing = this.players.find(
|
||||||
(p) => p.clientId === socket.handshake.auth.clientId
|
(p) => p.clientId === socket.handshake.auth.clientId
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isAlreadyConnected) {
|
if (existing) {
|
||||||
socket.emit("game:errorMessage", "Player already connected")
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +245,7 @@ class Game {
|
|||||||
this.players.push(playerData)
|
this.players.push(playerData)
|
||||||
|
|
||||||
this.io.to(this.manager.id).emit("manager:newPlayer", playerData)
|
this.io.to(this.manager.id).emit("manager:newPlayer", playerData)
|
||||||
|
this.io.to(this.manager.id).emit("manager:players", this.players)
|
||||||
this.io.to(this.gameId).emit("game:totalPlayers", this.players.length)
|
this.io.to(this.gameId).emit("game:totalPlayers", this.players.length)
|
||||||
|
|
||||||
socket.emit("game:successJoin", this.gameId)
|
socket.emit("game:successJoin", this.gameId)
|
||||||
@@ -168,6 +270,7 @@ class Game {
|
|||||||
.to(player.id)
|
.to(player.id)
|
||||||
.emit("game:reset", "You have been kicked by the manager")
|
.emit("game:reset", "You have been kicked by the manager")
|
||||||
this.io.to(this.manager.id).emit("manager:playerKicked", player.id)
|
this.io.to(this.manager.id).emit("manager:playerKicked", player.id)
|
||||||
|
this.io.to(this.manager.id).emit("manager:players", this.players)
|
||||||
|
|
||||||
this.io.to(this.gameId).emit("game:totalPlayers", this.players.length)
|
this.io.to(this.gameId).emit("game:totalPlayers", this.players.length)
|
||||||
}
|
}
|
||||||
@@ -181,6 +284,7 @@ class Game {
|
|||||||
} else {
|
} else {
|
||||||
this.reconnectPlayer(socket)
|
this.reconnectPlayer(socket)
|
||||||
}
|
}
|
||||||
|
this.io.to(this.manager.id).emit("manager:players", this.players)
|
||||||
}
|
}
|
||||||
|
|
||||||
private reconnectManager(socket: Socket) {
|
private reconnectManager(socket: Socket) {
|
||||||
@@ -210,6 +314,10 @@ class Game {
|
|||||||
players: this.players,
|
players: this.players,
|
||||||
})
|
})
|
||||||
socket.emit("game:totalPlayers", this.players.length)
|
socket.emit("game:totalPlayers", this.players.length)
|
||||||
|
if (this.breakActive) {
|
||||||
|
socket.emit("manager:break", true)
|
||||||
|
socket.emit("game:break", true)
|
||||||
|
}
|
||||||
|
|
||||||
registry.reactivateGame(this.gameId)
|
registry.reactivateGame(this.gameId)
|
||||||
console.log(`Manager reconnected to game ${this.inviteCode}`)
|
console.log(`Manager reconnected to game ${this.inviteCode}`)
|
||||||
@@ -246,6 +354,7 @@ class Game {
|
|||||||
this.playerStatus.delete(oldSocketId)
|
this.playerStatus.delete(oldSocketId)
|
||||||
this.playerStatus.set(socket.id, oldStatus)
|
this.playerStatus.set(socket.id, oldStatus)
|
||||||
}
|
}
|
||||||
|
this.io.to(this.manager.id).emit("manager:players", this.players)
|
||||||
|
|
||||||
socket.emit("player:successReconnect", {
|
socket.emit("player:successReconnect", {
|
||||||
gameId: this.gameId,
|
gameId: this.gameId,
|
||||||
@@ -260,6 +369,9 @@ class Game {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
socket.emit("game:totalPlayers", this.players.length)
|
socket.emit("game:totalPlayers", this.players.length)
|
||||||
|
if (this.breakActive) {
|
||||||
|
socket.emit("game:break", true)
|
||||||
|
}
|
||||||
console.log(
|
console.log(
|
||||||
`Player ${player.username} reconnected to game ${this.inviteCode}`
|
`Player ${player.username} reconnected to game ${this.inviteCode}`
|
||||||
)
|
)
|
||||||
@@ -271,26 +383,118 @@ class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.cooldown.active = true
|
this.cooldown.active = true
|
||||||
let count = seconds - 1
|
this.cooldown.paused = false
|
||||||
|
this.cooldown.remaining = seconds
|
||||||
|
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
const cooldownTimeout = setInterval(() => {
|
this.cooldown.resolve = resolve
|
||||||
if (!this.cooldown.active || count <= 0) {
|
|
||||||
this.cooldown.active = false
|
|
||||||
clearInterval(cooldownTimeout)
|
|
||||||
resolve()
|
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (!this.cooldown.active) {
|
||||||
|
this.finishCooldown()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.io.to(this.gameId).emit("game:cooldown", count)
|
if (this.cooldown.paused) {
|
||||||
count -= 1
|
return
|
||||||
}, 1000)
|
}
|
||||||
|
|
||||||
|
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() {
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
setBreak(socket: Socket, active: boolean) {
|
||||||
|
if (this.manager.id !== socket.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.breakActive = active
|
||||||
|
|
||||||
|
if (this.cooldown.active) {
|
||||||
|
if (active) {
|
||||||
|
this.cooldown.paused = true
|
||||||
|
} else {
|
||||||
|
this.cooldown.paused = false
|
||||||
|
}
|
||||||
|
this.io.to(this.gameId).emit("game:cooldownPause", this.cooldown.paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.io.to(this.gameId).emit("game:break", active)
|
||||||
|
this.io.to(this.manager.id).emit("manager:break", active)
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
skipQuestionIntro(socket: Socket) {
|
||||||
|
if (this.manager.id !== socket.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.started) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.abortCooldown()
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(socket: Socket) {
|
async start(socket: Socket) {
|
||||||
@@ -315,6 +519,7 @@ class Game {
|
|||||||
await this.startCooldown(3)
|
await this.startCooldown(3)
|
||||||
|
|
||||||
this.newRound()
|
this.newRound()
|
||||||
|
this.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
async newRound() {
|
async newRound() {
|
||||||
@@ -350,7 +555,7 @@ class Game {
|
|||||||
cooldown: question.cooldown,
|
cooldown: question.cooldown,
|
||||||
})
|
})
|
||||||
|
|
||||||
await sleep(question.cooldown)
|
await this.startCooldown(question.cooldown)
|
||||||
|
|
||||||
if (!this.started) {
|
if (!this.started) {
|
||||||
return
|
return
|
||||||
@@ -374,6 +579,7 @@ class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.showResults(question)
|
this.showResults(question)
|
||||||
|
this.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
showResults(question: any) {
|
showResults(question: any) {
|
||||||
@@ -439,6 +645,7 @@ class Game {
|
|||||||
this.tempOldLeaderboard = oldLeaderboard
|
this.tempOldLeaderboard = oldLeaderboard
|
||||||
|
|
||||||
this.round.playersAnswers = []
|
this.round.playersAnswers = []
|
||||||
|
this.persist()
|
||||||
}
|
}
|
||||||
selectAnswer(socket: Socket, answerId: number) {
|
selectAnswer(socket: Socket, answerId: number) {
|
||||||
const player = this.players.find((player) => player.id === socket.id)
|
const player = this.players.find((player) => player.id === socket.id)
|
||||||
@@ -471,6 +678,7 @@ class Game {
|
|||||||
if (this.round.playersAnswers.length === this.players.length) {
|
if (this.round.playersAnswers.length === this.players.length) {
|
||||||
this.abortCooldown()
|
this.abortCooldown()
|
||||||
}
|
}
|
||||||
|
this.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
nextRound(socket: Socket) {
|
nextRound(socket: Socket) {
|
||||||
@@ -513,6 +721,7 @@ class Game {
|
|||||||
subject: this.quizz.subject,
|
subject: this.quizz.subject,
|
||||||
top: this.leaderboard.slice(0, 3),
|
top: this.leaderboard.slice(0, 3),
|
||||||
})
|
})
|
||||||
|
this.clearPersisted()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -527,6 +736,17 @@ class Game {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.tempOldLeaderboard = null
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { createClient } from "redis"
|
||||||
|
|
||||||
|
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379"
|
||||||
|
|
||||||
|
const redis =
|
||||||
|
createClient({ url: redisUrl })
|
||||||
|
.on("error", (err) => console.error("Redis Client Error", err))
|
||||||
|
|
||||||
|
export type GameSnapshot = Record<string, any>
|
||||||
|
|
||||||
|
export const connectRedis = async () => {
|
||||||
|
if (!redis.isOpen) {
|
||||||
|
await redis.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveSnapshot = async (gameId: string, snapshot: GameSnapshot) => {
|
||||||
|
if (!gameId) return
|
||||||
|
await connectRedis()
|
||||||
|
await redis.set(`game:${gameId}`, JSON.stringify(snapshot), {
|
||||||
|
EX: 60 * 60 * 6, // 6 hours
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadSnapshot = async (gameId: string): Promise<GameSnapshot | null> => {
|
||||||
|
if (!gameId) return null
|
||||||
|
await connectRedis()
|
||||||
|
const raw = await redis.get(`game:${gameId}`)
|
||||||
|
return raw ? (JSON.parse(raw) as GameSnapshot) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteSnapshot = async (gameId: string) => {
|
||||||
|
if (!gameId) return
|
||||||
|
await connectRedis()
|
||||||
|
await redis.del(`game:${gameId}`)
|
||||||
|
}
|
||||||
@@ -86,6 +86,9 @@ class Registry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeGame(gameId: string): boolean {
|
removeGame(gameId: string): boolean {
|
||||||
|
const game = this.games.find((g) => g.gameId === gameId)
|
||||||
|
void game?.clearPersisted?.()
|
||||||
|
|
||||||
const initialLength = this.games.length
|
const initialLength = this.games.length
|
||||||
this.games = this.games.filter((g) => g.gameId !== gameId)
|
this.games = this.games.filter((g) => g.gameId !== gameId)
|
||||||
this.emptyGames = this.emptyGames.filter((g) => g.game.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 removed = this.emptyGames.filter((g) => !stillEmpty.includes(g))
|
||||||
const removedGameIds = removed.map((r) => r.game.gameId)
|
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.games = this.games.filter((g) => !removedGameIds.includes(g.gameId))
|
||||||
this.emptyGames = stillEmpty
|
this.emptyGames = stillEmpty
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import logo from "@rahoot/web/assets/logo.svg"
|
import BrandHeading from "@rahoot/web/components/BrandHeading"
|
||||||
import Loader from "@rahoot/web/components/Loader"
|
import Loader from "@rahoot/web/components/Loader"
|
||||||
import { useSocket } from "@rahoot/web/contexts/socketProvider"
|
import { useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||||
import Image from "next/image"
|
|
||||||
import { PropsWithChildren, useEffect } from "react"
|
import { PropsWithChildren, useEffect } from "react"
|
||||||
|
|
||||||
const AuthLayout = ({ children }: PropsWithChildren) => {
|
const AuthLayout = ({ children }: PropsWithChildren) => {
|
||||||
@@ -14,34 +13,34 @@ const AuthLayout = ({ children }: PropsWithChildren) => {
|
|||||||
}
|
}
|
||||||
}, [connect, isConnected])
|
}, [connect, isConnected])
|
||||||
|
|
||||||
if (!isConnected) {
|
const Shell = ({ children: inner }: PropsWithChildren) => (
|
||||||
return (
|
<section className="relative flex min-h-screen flex-col items-center justify-center px-4 text-center">
|
||||||
<section className="relative flex min-h-screen flex-col items-center justify-center">
|
<div className="pointer-events-none absolute inset-0 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 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 className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Image src={logo} className="mb-6 h-32" alt="logo" />
|
<div className="z-10 flex w-full flex-col items-center gap-8 px-4">
|
||||||
|
<BrandHeading size="md" />
|
||||||
|
<div className="flex w-full justify-center">{inner}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
<Loader className="h-23" />
|
<Loader className="h-23" />
|
||||||
<h2 className="mt-2 text-center text-2xl font-bold text-white drop-shadow-lg md:text-3xl">
|
<h2 className="text-2xl font-bold text-white drop-shadow-lg md:text-3xl">
|
||||||
Loading...
|
Loading...
|
||||||
</h2>
|
</h2>
|
||||||
</section>
|
</div>
|
||||||
|
</Shell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <Shell>{children}</Shell>
|
||||||
<section className="relative flex min-h-screen flex-col items-center justify-center">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<Image src={logo} className="mb-6 h-32" alt="logo" />
|
|
||||||
{children}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AuthLayout
|
export default AuthLayout
|
||||||
|
|||||||
@@ -4,20 +4,26 @@ import { QuizzWithId } from "@rahoot/common/types/game"
|
|||||||
import { STATUS } from "@rahoot/common/types/game/status"
|
import { STATUS } from "@rahoot/common/types/game/status"
|
||||||
import ManagerPassword from "@rahoot/web/components/game/create/ManagerPassword"
|
import ManagerPassword from "@rahoot/web/components/game/create/ManagerPassword"
|
||||||
import QuizEditor from "@rahoot/web/components/game/create/QuizEditor"
|
import QuizEditor from "@rahoot/web/components/game/create/QuizEditor"
|
||||||
|
import MediaLibrary from "@rahoot/web/components/game/create/MediaLibrary"
|
||||||
|
import ThemeEditor from "@rahoot/web/components/game/create/ThemeEditor"
|
||||||
import SelectQuizz from "@rahoot/web/components/game/create/SelectQuizz"
|
import SelectQuizz from "@rahoot/web/components/game/create/SelectQuizz"
|
||||||
|
import Button from "@rahoot/web/components/Button"
|
||||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||||
import { useManagerStore } from "@rahoot/web/stores/manager"
|
import { useManagerStore } from "@rahoot/web/stores/manager"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
const Manager = () => {
|
const Manager = () => {
|
||||||
const { setGameId, setStatus } = useManagerStore()
|
const { gameId, setGameId, setStatus } = useManagerStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { socket } = useSocket()
|
const { socket } = useSocket()
|
||||||
|
|
||||||
const [isAuth, setIsAuth] = useState(false)
|
const [isAuth, setIsAuth] = useState(false)
|
||||||
const [quizzList, setQuizzList] = useState<QuizzWithId[]>([])
|
const [quizzList, setQuizzList] = useState<QuizzWithId[]>([])
|
||||||
const [showEditor, setShowEditor] = useState(false)
|
const [showEditor, setShowEditor] = useState(false)
|
||||||
|
const [showMedia, setShowMedia] = useState(false)
|
||||||
|
const [showTheme, setShowTheme] = useState(false)
|
||||||
|
const [resumeGameId, setResumeGameId] = useState<string | null>(null)
|
||||||
|
|
||||||
useEvent("manager:quizzList", (quizzList) => {
|
useEvent("manager:quizzList", (quizzList) => {
|
||||||
setIsAuth(true)
|
setIsAuth(true)
|
||||||
@@ -27,15 +33,44 @@ const Manager = () => {
|
|||||||
useEvent("manager:gameCreated", ({ gameId, inviteCode }) => {
|
useEvent("manager:gameCreated", ({ gameId, inviteCode }) => {
|
||||||
setGameId(gameId)
|
setGameId(gameId)
|
||||||
setStatus(STATUS.SHOW_ROOM, { text: "Waiting for the players", inviteCode })
|
setStatus(STATUS.SHOW_ROOM, { text: "Waiting for the players", inviteCode })
|
||||||
|
setResumeGameId(gameId)
|
||||||
router.push(`/game/manager/${gameId}`)
|
router.push(`/game/manager/${gameId}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEvent(
|
||||||
|
"manager:successReconnect",
|
||||||
|
({ gameId, status, players, currentQuestion }) => {
|
||||||
|
setGameId(gameId)
|
||||||
|
setStatus(status.name, status.data)
|
||||||
|
setResumeGameId(gameId)
|
||||||
|
router.push(`/game/manager/${gameId}`)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const handleAuth = (password: string) => {
|
const handleAuth = (password: string) => {
|
||||||
socket?.emit("manager:auth", password)
|
socket?.emit("manager:auth", password)
|
||||||
}
|
}
|
||||||
const handleCreate = (quizzId: string) => {
|
const handleCreate = (quizzId: string) => {
|
||||||
socket?.emit("game:create", quizzId)
|
socket?.emit("game:create", quizzId)
|
||||||
}
|
}
|
||||||
|
const handleBreakToggle = (active: boolean) => {
|
||||||
|
if (!gameId) return
|
||||||
|
socket?.emit("manager:setBreak", { gameId, active })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResume = () => {
|
||||||
|
if (!resumeGameId) return
|
||||||
|
socket?.emit("manager:reconnect", { gameId: resumeGameId })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("last_manager_game_id")
|
||||||
|
if (stored) {
|
||||||
|
setResumeGameId(stored)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (!isAuth) {
|
if (!isAuth) {
|
||||||
return <ManagerPassword onSubmit={handleAuth} />
|
return <ManagerPassword onSubmit={handleAuth} />
|
||||||
@@ -47,15 +82,42 @@ const Manager = () => {
|
|||||||
quizzList={quizzList}
|
quizzList={quizzList}
|
||||||
onBack={() => setShowEditor(false)}
|
onBack={() => setShowEditor(false)}
|
||||||
onListUpdate={setQuizzList}
|
onListUpdate={setQuizzList}
|
||||||
|
onBreakToggle={handleBreakToggle}
|
||||||
|
gameId={gameId}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showMedia) {
|
||||||
|
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 gap-2">
|
||||||
|
<Button onClick={() => setShowMedia(false)} className="bg-gray-700">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-col leading-tight">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Media library</h2>
|
||||||
|
<p className="text-xs text-gray-500">Upload, view, and delete unused media.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MediaLibrary />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showTheme) {
|
||||||
|
return <ThemeEditor onBack={() => setShowTheme(false)} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectQuizz
|
<SelectQuizz
|
||||||
quizzList={quizzList}
|
quizzList={quizzList}
|
||||||
onSelect={handleCreate}
|
onSelect={handleCreate}
|
||||||
onManage={() => setShowEditor(true)}
|
onManage={() => setShowEditor(true)}
|
||||||
|
onMedia={() => setShowMedia(true)}
|
||||||
|
onTheme={() => setShowTheme(true)}
|
||||||
|
resumeGameId={resumeGameId}
|
||||||
|
onResume={handleResume}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import Username from "@rahoot/web/components/game/join/Username"
|
|||||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||||
import { usePlayerStore } from "@rahoot/web/stores/player"
|
import { usePlayerStore } from "@rahoot/web/stores/player"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import toast from "react-hot-toast"
|
import toast from "react-hot-toast"
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
const { isConnected, connect } = useSocket()
|
const { isConnected, connect, socket } = useSocket()
|
||||||
const { player } = usePlayerStore()
|
const { player } = usePlayerStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
@@ -17,6 +19,19 @@ const Home = () => {
|
|||||||
}
|
}
|
||||||
}, [connect, isConnected])
|
}, [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) => {
|
useEvent("game:errorMessage", (message) => {
|
||||||
toast.error(message)
|
toast.error(message)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { getTheme, saveTheme } from "@rahoot/web/server/theme"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const theme = getTheme()
|
||||||
|
return NextResponse.json({ theme })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load theme", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load theme" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const theme = saveTheme({
|
||||||
|
brandName: body.brandName,
|
||||||
|
backgroundUrl: body.backgroundUrl,
|
||||||
|
})
|
||||||
|
return NextResponse.json({ theme })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save theme", error)
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to save theme"
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,13 +13,15 @@ import { usePlayerStore } from "@rahoot/web/stores/player"
|
|||||||
import { useQuestionStore } from "@rahoot/web/stores/question"
|
import { useQuestionStore } from "@rahoot/web/stores/question"
|
||||||
import { GAME_STATE_COMPONENTS } from "@rahoot/web/utils/constants"
|
import { GAME_STATE_COMPONENTS } from "@rahoot/web/utils/constants"
|
||||||
import { useParams, useRouter } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation"
|
||||||
|
import { useEffect } from "react"
|
||||||
import toast from "react-hot-toast"
|
import toast from "react-hot-toast"
|
||||||
|
|
||||||
const Game = () => {
|
const Game = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { socket } = useSocket()
|
const { socket } = useSocket()
|
||||||
const { gameId: gameIdParam }: { gameId?: string } = useParams()
|
const { gameId: gameIdParam }: { gameId?: string } = useParams()
|
||||||
const { status, setPlayer, setGameId, setStatus, reset } = usePlayerStore()
|
const { status, player, setPlayer, setGameId, setStatus, reset } =
|
||||||
|
usePlayerStore()
|
||||||
const { setQuestionStates } = useQuestionStore()
|
const { setQuestionStates } = useQuestionStore()
|
||||||
|
|
||||||
useEvent("connect", () => {
|
useEvent("connect", () => {
|
||||||
@@ -35,6 +37,12 @@ const Game = () => {
|
|||||||
setStatus(status.name, status.data)
|
setStatus(status.name, status.data)
|
||||||
setPlayer(player)
|
setPlayer(player)
|
||||||
setQuestionStates(currentQuestion)
|
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("/")
|
router.replace("/")
|
||||||
reset()
|
reset()
|
||||||
setQuestionStates(null)
|
setQuestionStates(null)
|
||||||
|
try {
|
||||||
|
localStorage.removeItem("last_game_id")
|
||||||
|
localStorage.removeItem("last_username")
|
||||||
|
localStorage.removeItem("last_points")
|
||||||
|
} catch {}
|
||||||
toast.error(message)
|
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) {
|
if (!gameIdParam) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useQuestionStore } from "@rahoot/web/stores/question"
|
|||||||
import { GAME_STATE_COMPONENTS_MANAGER } from "@rahoot/web/utils/constants"
|
import { GAME_STATE_COMPONENTS_MANAGER } from "@rahoot/web/utils/constants"
|
||||||
import { useParams, useRouter } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation"
|
||||||
import toast from "react-hot-toast"
|
import toast from "react-hot-toast"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
const ManagerGame = () => {
|
const ManagerGame = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -24,6 +25,9 @@ const ManagerGame = () => {
|
|||||||
const { gameId, status, setGameId, setStatus, setPlayers, reset } =
|
const { gameId, status, setGameId, setStatus, setPlayers, reset } =
|
||||||
useManagerStore()
|
useManagerStore()
|
||||||
const { setQuestionStates } = useQuestionStore()
|
const { setQuestionStates } = useQuestionStore()
|
||||||
|
const [cooldownPaused, setCooldownPaused] = useState(false)
|
||||||
|
const [breakActive, setBreakActive] = useState(false)
|
||||||
|
const { players } = useManagerStore()
|
||||||
|
|
||||||
useEvent("game:status", ({ name, data }) => {
|
useEvent("game:status", ({ name, data }) => {
|
||||||
if (name in GAME_STATE_COMPONENTS_MANAGER) {
|
if (name in GAME_STATE_COMPONENTS_MANAGER) {
|
||||||
@@ -54,6 +58,25 @@ const ManagerGame = () => {
|
|||||||
toast.error(message)
|
toast.error(message)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEvent("manager:newPlayer", (player) => {
|
||||||
|
setPlayers((prev) => [...prev.filter((p) => p.id !== player.id), player])
|
||||||
|
})
|
||||||
|
|
||||||
|
useEvent("manager:removePlayer", (playerId) => {
|
||||||
|
setPlayers((prev) => prev.filter((p) => p.id !== playerId))
|
||||||
|
})
|
||||||
|
|
||||||
|
useEvent("manager:players", (players) => {
|
||||||
|
setPlayers(players)
|
||||||
|
})
|
||||||
|
|
||||||
|
useEvent("game:cooldownPause", (isPaused) => {
|
||||||
|
setCooldownPaused(isPaused)
|
||||||
|
})
|
||||||
|
|
||||||
|
useEvent("game:break", (active) => setBreakActive(active))
|
||||||
|
useEvent("manager:break", (active) => setBreakActive(active))
|
||||||
|
|
||||||
const handleSkip = () => {
|
const handleSkip = () => {
|
||||||
if (!gameId) {
|
if (!gameId) {
|
||||||
return
|
return
|
||||||
@@ -65,6 +88,11 @@ const ManagerGame = () => {
|
|||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case STATUS.SHOW_QUESTION:
|
||||||
|
socket?.emit("manager:skipQuestionIntro", { gameId })
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
case STATUS.SELECT_ANSWER:
|
case STATUS.SELECT_ANSWER:
|
||||||
socket?.emit("manager:abortQuiz", { gameId })
|
socket?.emit("manager:abortQuiz", { gameId })
|
||||||
|
|
||||||
@@ -82,6 +110,25 @@ const ManagerGame = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePauseToggle = () => {
|
||||||
|
if (!gameId) return
|
||||||
|
if (cooldownPaused) {
|
||||||
|
socket?.emit("manager:resumeCooldown", { gameId })
|
||||||
|
} else {
|
||||||
|
socket?.emit("manager:pauseCooldown", { gameId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBreakToggle = () => {
|
||||||
|
if (!gameId) return
|
||||||
|
socket?.emit("manager:setBreak", { gameId, active: !breakActive })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEndGame = () => {
|
||||||
|
if (!gameId) return
|
||||||
|
socket?.emit("manager:endGame", { gameId })
|
||||||
|
}
|
||||||
|
|
||||||
let component = null
|
let component = null
|
||||||
|
|
||||||
switch (status?.name) {
|
switch (status?.name) {
|
||||||
@@ -127,7 +174,20 @@ const ManagerGame = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
||||||
|
}
|
||||||
|
onBreakToggle={handleBreakToggle}
|
||||||
|
breakActive={breakActive}
|
||||||
|
onEnd={handleEndGame}
|
||||||
|
players={players}
|
||||||
|
manager
|
||||||
|
>
|
||||||
{component}
|
{component}
|
||||||
</GameWrapper>
|
</GameWrapper>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
import Toaster from "@rahoot/web/components/Toaster"
|
import Toaster from "@rahoot/web/components/Toaster"
|
||||||
|
import BrandingHelmet from "@rahoot/web/components/BrandingHelmet"
|
||||||
|
import ThemeHydrator from "@rahoot/web/components/ThemeHydrator"
|
||||||
import { SocketProvider } from "@rahoot/web/contexts/socketProvider"
|
import { SocketProvider } from "@rahoot/web/contexts/socketProvider"
|
||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import { Montserrat } from "next/font/google"
|
import { Montserrat } from "next/font/google"
|
||||||
import { PropsWithChildren } from "react"
|
import { PropsWithChildren } from "react"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
|
import { getTheme } from "@rahoot/web/server/theme"
|
||||||
|
|
||||||
const montserrat = Montserrat({
|
const montserrat = Montserrat({
|
||||||
variable: "--font-montserrat",
|
variable: "--font-montserrat",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
})
|
})
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: "Rahoot !",
|
const theme = getTheme()
|
||||||
|
return {
|
||||||
|
title: theme.brandName || "Rahoot",
|
||||||
icons: "/icon.svg",
|
icons: "/icon.svg",
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const RootLayout = ({ children }: PropsWithChildren) => (
|
const RootLayout = ({ children }: PropsWithChildren) => (
|
||||||
<html lang="en" suppressHydrationWarning={true} data-lt-installed="true">
|
<html lang="en" suppressHydrationWarning={true} data-lt-installed="true">
|
||||||
<body className={`${montserrat.variable} bg-secondary antialiased`}>
|
<body className={`${montserrat.variable} bg-secondary antialiased`}>
|
||||||
<SocketProvider>
|
<SocketProvider>
|
||||||
|
<BrandingHelmet />
|
||||||
|
<ThemeHydrator />
|
||||||
<main className="text-base-[8px] flex flex-col">{children}</main>
|
<main className="text-base-[8px] flex flex-col">{children}</main>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</SocketProvider>
|
</SocketProvider>
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useThemeStore } from "@rahoot/web/stores/theme"
|
||||||
|
import clsx from "clsx"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string
|
||||||
|
size?: "md" | "lg"
|
||||||
|
}
|
||||||
|
|
||||||
|
const BrandHeading = ({ className, size = "lg" }: Props) => {
|
||||||
|
const { brandName } = useThemeStore()
|
||||||
|
const label = brandName || "Rahoot!"
|
||||||
|
|
||||||
|
const sizeClass =
|
||||||
|
size === "lg"
|
||||||
|
? "text-4xl md:text-5xl lg:text-6xl"
|
||||||
|
: "text-2xl md:text-3xl"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"text-center font-black tracking-tight text-[#f7931e] drop-shadow-lg leading-tight",
|
||||||
|
sizeClass,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BrandHeading
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useThemeStore } from "@rahoot/web/stores/theme"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
const BrandingHelmet = () => {
|
||||||
|
const { brandName } = useThemeStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (brandName) {
|
||||||
|
document.title = brandName
|
||||||
|
}
|
||||||
|
}, [brandName])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BrandingHelmet
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PropsWithChildren } from "react"
|
import { PropsWithChildren } from "react"
|
||||||
|
|
||||||
const Form = ({ children }: PropsWithChildren) => (
|
const Form = ({ children }: PropsWithChildren) => (
|
||||||
<div className="z-10 flex w-full max-w-80 flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
|
<div className="z-10 mx-auto flex w-full max-w-80 flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useThemeStore } from "@rahoot/web/stores/theme"
|
||||||
|
|
||||||
|
const ThemeHydrator = () => {
|
||||||
|
const { setBackground, setBrandName } = useThemeStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/theme", { cache: "no-store" })
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok && data.theme) {
|
||||||
|
if (typeof data.theme.backgroundUrl === "string") {
|
||||||
|
setBackground(data.theme.backgroundUrl || null)
|
||||||
|
}
|
||||||
|
if (typeof data.theme.brandName === "string") {
|
||||||
|
setBrandName(data.theme.brandName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to hydrate theme", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [setBackground, setBrandName])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeHydrator
|
||||||
@@ -7,22 +7,43 @@ import Loader from "@rahoot/web/components/Loader"
|
|||||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||||
import { usePlayerStore } from "@rahoot/web/stores/player"
|
import { usePlayerStore } from "@rahoot/web/stores/player"
|
||||||
import { useQuestionStore } from "@rahoot/web/stores/question"
|
import { useQuestionStore } from "@rahoot/web/stores/question"
|
||||||
|
import { useThemeStore } from "@rahoot/web/stores/theme"
|
||||||
import { MANAGER_SKIP_BTN } from "@rahoot/web/utils/constants"
|
import { MANAGER_SKIP_BTN } from "@rahoot/web/utils/constants"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
import Image from "next/image"
|
|
||||||
import { PropsWithChildren, useEffect, useState } from "react"
|
import { PropsWithChildren, useEffect, useState } from "react"
|
||||||
|
|
||||||
type Props = PropsWithChildren & {
|
type Props = PropsWithChildren & {
|
||||||
statusName: Status | undefined
|
statusName: Status | undefined
|
||||||
onNext?: () => void
|
onNext?: () => void
|
||||||
|
onPause?: () => void
|
||||||
|
paused?: boolean
|
||||||
|
showPause?: boolean
|
||||||
|
onEnd?: () => void
|
||||||
|
players?: { id: string; username: string; connected: boolean }[]
|
||||||
manager?: boolean
|
manager?: boolean
|
||||||
|
onBreakToggle?: () => void
|
||||||
|
breakActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const GameWrapper = ({ children, statusName, onNext, manager }: Props) => {
|
const GameWrapper = ({
|
||||||
|
children,
|
||||||
|
statusName,
|
||||||
|
onNext,
|
||||||
|
onPause,
|
||||||
|
paused,
|
||||||
|
showPause,
|
||||||
|
onEnd,
|
||||||
|
players,
|
||||||
|
manager,
|
||||||
|
onBreakToggle,
|
||||||
|
breakActive,
|
||||||
|
}: Props) => {
|
||||||
const { isConnected } = useSocket()
|
const { isConnected } = useSocket()
|
||||||
const { player } = usePlayerStore()
|
const { player } = usePlayerStore()
|
||||||
const { questionStates, setQuestionStates } = useQuestionStore()
|
const { questionStates, setQuestionStates } = useQuestionStore()
|
||||||
|
const { backgroundUrl, setBackground, setBrandName } = useThemeStore()
|
||||||
const [isDisabled, setIsDisabled] = useState(false)
|
const [isDisabled, setIsDisabled] = useState(false)
|
||||||
|
const [onBreak, setOnBreak] = useState(false)
|
||||||
const next = statusName ? MANAGER_SKIP_BTN[statusName] : null
|
const next = statusName ? MANAGER_SKIP_BTN[statusName] : null
|
||||||
|
|
||||||
useEvent("game:updateQuestion", ({ current, total }) => {
|
useEvent("game:updateQuestion", ({ current, total }) => {
|
||||||
@@ -36,19 +57,50 @@ const GameWrapper = ({ children, statusName, onNext, manager }: Props) => {
|
|||||||
setIsDisabled(false)
|
setIsDisabled(false)
|
||||||
}, [statusName])
|
}, [statusName])
|
||||||
|
|
||||||
|
useEvent("game:break", (active) => setOnBreak(active))
|
||||||
|
useEvent("manager:break", (active) => setOnBreak(active))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTheme = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/theme", { cache: "no-store" })
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok && data.theme) {
|
||||||
|
if (typeof data.theme.backgroundUrl === "string") {
|
||||||
|
setBackground(data.theme.backgroundUrl || null)
|
||||||
|
}
|
||||||
|
if (typeof data.theme.brandName === "string") {
|
||||||
|
setBrandName(data.theme.brandName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load theme", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTheme()
|
||||||
|
}, [setBackground, setBrandName])
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
setIsDisabled(true)
|
setIsDisabled(true)
|
||||||
onNext?.()
|
onNext?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedBackground = backgroundUrl || background.src
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative flex min-h-screen w-full flex-col justify-between">
|
<section className="relative flex min-h-screen w-full flex-col justify-between">
|
||||||
<div className="fixed top-0 left-0 -z-10 h-full w-full bg-orange-600 opacity-70">
|
<div
|
||||||
<Image
|
className="fixed top-0 left-0 -z-10 h-full w-full bg-orange-600 opacity-70"
|
||||||
className="pointer-events-none h-full w-full object-cover opacity-60"
|
style={{
|
||||||
src={background}
|
backgroundImage: `url(${resolvedBackground})`,
|
||||||
alt="background"
|
backgroundSize: "cover",
|
||||||
/>
|
backgroundPosition: "center",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="h-full w-full bg-black/10" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isConnected && !statusName ? (
|
{!isConnected && !statusName ? (
|
||||||
@@ -75,8 +127,59 @@ const GameWrapper = ({ children, statusName, onNext, manager }: Props) => {
|
|||||||
{next}
|
{next}
|
||||||
</Button>
|
</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 && onBreakToggle && (
|
||||||
|
<Button
|
||||||
|
className={clsx("self-end bg-white px-4 text-black!", {
|
||||||
|
"pointer-events-none": isDisabled,
|
||||||
|
})}
|
||||||
|
onClick={onBreakToggle}
|
||||||
|
>
|
||||||
|
{breakActive ? "Resume game" : "Break"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{manager && onEnd && (
|
||||||
|
<Button className="self-end bg-red-600 px-4" onClick={onEnd}>
|
||||||
|
End game
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{manager && players && players.length > 0 && (
|
||||||
|
<div className="mx-4 mb-2 rounded-md bg-white/90 p-3 text-sm shadow">
|
||||||
|
<div className="mb-1 text-xs font-semibold uppercase text-gray-600">
|
||||||
|
Players ({players.length})
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{players.map((p) => (
|
||||||
|
<span
|
||||||
|
key={p.id}
|
||||||
|
className={clsx(
|
||||||
|
"rounded border px-2 py-1 font-semibold",
|
||||||
|
p.connected
|
||||||
|
? "border-green-500 text-green-700"
|
||||||
|
: "border-gray-300 text-gray-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.username || p.id} {p.connected ? "" : "(disc.)"}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
{!manager && (
|
{!manager && (
|
||||||
@@ -87,6 +190,15 @@ const GameWrapper = ({ children, statusName, onNext, manager }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onBreak && (
|
||||||
|
<div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center bg-black/60">
|
||||||
|
<div className="rounded-md bg-white/90 px-6 py-4 text-center shadow-lg">
|
||||||
|
<p className="text-lg font-semibold text-gray-800">Game paused for a break</p>
|
||||||
|
<p className="text-sm text-gray-600">We'll resume from the same spot.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,184 +1,49 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { QuestionMedia } from "@rahoot/common/types/game"
|
import type { QuestionMedia as QuestionMediaType } from "@rahoot/common/types/game"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useState } from "react"
|
||||||
|
|
||||||
type YoutubeAPI = {
|
|
||||||
Player: new (_element: string, _options: any) => {
|
|
||||||
destroy: () => void
|
|
||||||
}
|
|
||||||
PlayerState: Record<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
let youtubeApiPromise: Promise<YoutubeAPI | null> | null = null
|
|
||||||
|
|
||||||
const loadYoutubeApi = () => {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return Promise.resolve(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingApi = (window as any).YT as YoutubeAPI | undefined
|
|
||||||
|
|
||||||
if (existingApi && existingApi.Player) {
|
|
||||||
return Promise.resolve(existingApi)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!youtubeApiPromise) {
|
|
||||||
youtubeApiPromise = new Promise((resolve) => {
|
|
||||||
const tag = document.createElement("script")
|
|
||||||
tag.src = "https://www.youtube.com/iframe_api"
|
|
||||||
tag.async = true
|
|
||||||
|
|
||||||
const handleError = () => resolve(null)
|
|
||||||
tag.onerror = handleError
|
|
||||||
|
|
||||||
const existing = document.querySelector(
|
|
||||||
'script[src="https://www.youtube.com/iframe_api"]',
|
|
||||||
)
|
|
||||||
if (existing) {
|
|
||||||
existing.addEventListener("error", handleError)
|
|
||||||
}
|
|
||||||
|
|
||||||
document.head.appendChild(tag)
|
|
||||||
|
|
||||||
const win = window as any
|
|
||||||
|
|
||||||
const prevOnReady = win.onYouTubeIframeAPIReady
|
|
||||||
win.onYouTubeIframeAPIReady = () => {
|
|
||||||
prevOnReady?.()
|
|
||||||
resolve(win.YT as YoutubeAPI)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return youtubeApiPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
const extractYoutubeId = (url: string) => {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url)
|
|
||||||
|
|
||||||
if (parsed.hostname.includes("youtu.be")) {
|
|
||||||
return parsed.pathname.replace("/", "")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.searchParams.get("v")) {
|
|
||||||
return parsed.searchParams.get("v")
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = parsed.pathname.split("/")
|
|
||||||
const embedIndex = parts.indexOf("embed")
|
|
||||||
if (embedIndex !== -1 && parts[embedIndex + 1]) {
|
|
||||||
return parts[embedIndex + 1]
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Invalid youtube url", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
media?: QuestionMedia
|
media?: QuestionMediaType
|
||||||
alt: string
|
alt: string
|
||||||
onPlayChange?: (_playing: boolean) => void
|
onPlayChange?: (_playing: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const QuestionMedia = ({ media, alt, onPlayChange }: Props) => {
|
const QuestionMedia = ({ media, alt, onPlayChange }: Props) => {
|
||||||
const youtubeContainerId = useMemo(
|
const [zoomed, setZoomed] = useState(false)
|
||||||
() => `yt-${Math.random().toString(36).slice(2, 10)}`,
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
const youtubePlayerRef = useRef<any | null>(null)
|
|
||||||
const youtubeMounted = useRef(false)
|
|
||||||
const [youtubeReady, setYoutubeReady] = useState(false)
|
|
||||||
const [youtubePlaying, setYoutubePlaying] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (media?.type !== "youtube") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
youtubeMounted.current = true
|
|
||||||
const videoId = extractYoutubeId(media.url)
|
|
||||||
|
|
||||||
if (!videoId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loadYoutubeApi().then((YT) => {
|
|
||||||
if (!YT || !youtubeMounted.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
youtubePlayerRef.current = new YT.Player(youtubeContainerId, {
|
|
||||||
videoId,
|
|
||||||
playerVars: {
|
|
||||||
modestbranding: 1,
|
|
||||||
rel: 0,
|
|
||||||
iv_load_policy: 3,
|
|
||||||
playsinline: 1,
|
|
||||||
controls: 0,
|
|
||||||
disablekb: 1,
|
|
||||||
fs: 0,
|
|
||||||
origin:
|
|
||||||
typeof window !== "undefined" ? window.location.origin : undefined,
|
|
||||||
showinfo: 0,
|
|
||||||
},
|
|
||||||
host: "https://www.youtube-nocookie.com",
|
|
||||||
events: {
|
|
||||||
onReady: () => {
|
|
||||||
setYoutubeReady(true)
|
|
||||||
},
|
|
||||||
onStateChange: (event) => {
|
|
||||||
const { data } = event
|
|
||||||
const isPlaying =
|
|
||||||
data === YT.PlayerState.PLAYING ||
|
|
||||||
data === YT.PlayerState.BUFFERING
|
|
||||||
const isStopped =
|
|
||||||
data === YT.PlayerState.PAUSED ||
|
|
||||||
data === YT.PlayerState.ENDED ||
|
|
||||||
data === YT.PlayerState.UNSTARTED
|
|
||||||
|
|
||||||
if (isPlaying) {
|
|
||||||
setYoutubePlaying(true)
|
|
||||||
onPlayChange?.(true)
|
|
||||||
} else if (isStopped) {
|
|
||||||
setYoutubePlaying(false)
|
|
||||||
onPlayChange?.(false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
youtubeMounted.current = false
|
|
||||||
youtubePlayerRef.current?.destroy()
|
|
||||||
youtubePlayerRef.current = null
|
|
||||||
setYoutubeReady(false)
|
|
||||||
setYoutubePlaying(false)
|
|
||||||
onPlayChange?.(false)
|
|
||||||
}
|
|
||||||
}, [media?.type, media?.url, onPlayChange, youtubeContainerId])
|
|
||||||
|
|
||||||
if (!media) {
|
if (!media) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerClass = "mx-auto flex w-full max-w-3xl justify-center"
|
const containerClass = "mx-auto flex w-full max-w-5xl justify-center"
|
||||||
|
|
||||||
switch (media.type) {
|
switch (media.type) {
|
||||||
case "image":
|
case "image":
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className={containerClass}>
|
<div className={containerClass}>
|
||||||
<img
|
<img
|
||||||
alt={alt}
|
alt={alt}
|
||||||
src={media.url}
|
src={media.url}
|
||||||
className="m-4 h-full max-h-[400px] min-h-[200px] w-auto max-w-full rounded-md object-contain shadow-lg"
|
className="m-4 h-full max-h-[400px] min-h-[200px] w-auto max-w-full cursor-zoom-in rounded-md object-contain shadow-lg"
|
||||||
|
onClick={() => setZoomed(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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":
|
case "audio":
|
||||||
@@ -186,6 +51,7 @@ const QuestionMedia = ({ media, alt, onPlayChange }: Props) => {
|
|||||||
<div className={clsx(containerClass, "px-4")}>
|
<div className={clsx(containerClass, "px-4")}>
|
||||||
<audio
|
<audio
|
||||||
controls
|
controls
|
||||||
|
crossOrigin="anonymous"
|
||||||
src={media.url}
|
src={media.url}
|
||||||
className="mt-4 w-full rounded-md bg-black/40 p-2 shadow-lg"
|
className="mt-4 w-full rounded-md bg-black/40 p-2 shadow-lg"
|
||||||
preload="none"
|
preload="none"
|
||||||
@@ -201,8 +67,10 @@ const QuestionMedia = ({ media, alt, onPlayChange }: Props) => {
|
|||||||
<div className={containerClass}>
|
<div className={containerClass}>
|
||||||
<video
|
<video
|
||||||
controls
|
controls
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
playsInline
|
||||||
src={media.url}
|
src={media.url}
|
||||||
className="m-4 w-full max-w-3xl rounded-md shadow-lg"
|
className="m-4 w-full max-w-5xl rounded-md shadow-lg"
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
onPlay={() => onPlayChange?.(true)}
|
onPlay={() => onPlayChange?.(true)}
|
||||||
onPause={() => onPlayChange?.(false)}
|
onPause={() => onPlayChange?.(false)}
|
||||||
@@ -211,60 +79,9 @@ const QuestionMedia = ({ media, alt, onPlayChange }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
case "youtube": {
|
default:
|
||||||
return (
|
|
||||||
<div className={clsx(containerClass, "px-4")}>
|
|
||||||
<div
|
|
||||||
className="relative mt-4 w-full overflow-hidden rounded-md shadow-lg bg-black"
|
|
||||||
style={{ paddingTop: "56.25%" }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
id={youtubeContainerId}
|
|
||||||
className={clsx(
|
|
||||||
"absolute left-0 top-0 h-full w-full",
|
|
||||||
youtubeReady ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/5 via-black/10 to-black/20" />
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center gap-3">
|
|
||||||
<button
|
|
||||||
className={clsx(
|
|
||||||
"pointer-events-auto rounded-full bg-white/80 px-4 py-2 text-sm font-semibold shadow",
|
|
||||||
(!youtubeReady) && "opacity-50",
|
|
||||||
)}
|
|
||||||
disabled={!youtubeReady}
|
|
||||||
onClick={() => {
|
|
||||||
const player = youtubePlayerRef.current
|
|
||||||
if (player && typeof player.playVideo === "function") {
|
|
||||||
player.playVideo()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{youtubePlaying ? "Playing" : "Play"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={clsx(
|
|
||||||
"pointer-events-auto rounded-full bg-white/80 px-4 py-2 text-sm font-semibold shadow",
|
|
||||||
(!youtubeReady) && "opacity-50",
|
|
||||||
)}
|
|
||||||
disabled={!youtubeReady}
|
|
||||||
onClick={() => {
|
|
||||||
const player = youtubePlayerRef.current
|
|
||||||
if (player && typeof player.pauseVideo === "function") {
|
|
||||||
player.pauseVideo()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Pause
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default QuestionMedia
|
export default QuestionMedia
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const ManagerPassword = ({ onSubmit }: Props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="w-full max-w-xl">
|
||||||
<Form>
|
<Form>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -36,6 +37,7 @@ const ManagerPassword = ({ onSubmit }: Props) => {
|
|||||||
/>
|
/>
|
||||||
<Button onClick={handleSubmit}>Submit</Button>
|
<Button onClick={handleSubmit}>Submit</Button>
|
||||||
</Form>
|
</Form>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,21 +1,37 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { QuestionMedia, QuizzWithId } from "@rahoot/common/types/game"
|
import type { QuestionMedia, QuizzWithId } from "@rahoot/common/types/game"
|
||||||
import Button from "@rahoot/web/components/Button"
|
import Button from "@rahoot/web/components/Button"
|
||||||
import Input from "@rahoot/web/components/Input"
|
import Input from "@rahoot/web/components/Input"
|
||||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import toast from "react-hot-toast"
|
import toast from "react-hot-toast"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
quizzList: QuizzWithId[]
|
quizzList: QuizzWithId[]
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onListUpdate: (_quizz: QuizzWithId[]) => void
|
onListUpdate: (_quizz: QuizzWithId[]) => void
|
||||||
|
onBreakToggle?: (_active: boolean) => void
|
||||||
|
gameId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type EditableQuestion = QuizzWithId["questions"][number]
|
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 => ({
|
const blankQuestion = (): EditableQuestion => ({
|
||||||
question: "",
|
question: "",
|
||||||
answers: ["", ""],
|
answers: ["", ""],
|
||||||
@@ -24,19 +40,40 @@ const blankQuestion = (): EditableQuestion => ({
|
|||||||
time: 20,
|
time: 20,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mediaTypes: QuestionMedia["type"][] = [
|
const mediaTypes: QuestionMedia["type"][] = ["image", "audio", "video"]
|
||||||
"image",
|
|
||||||
"audio",
|
|
||||||
"video",
|
|
||||||
"youtube",
|
|
||||||
]
|
|
||||||
|
|
||||||
const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
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,
|
||||||
|
onBreakToggle,
|
||||||
|
gameId,
|
||||||
|
}: Props) => {
|
||||||
const { socket } = useSocket()
|
const { socket } = useSocket()
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
const [draft, setDraft] = useState<QuizzWithId | null>(null)
|
const [draft, setDraft] = useState<QuizzWithId | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [loading, setLoading] = 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) => {
|
useEvent("manager:quizzLoaded", (quizz) => {
|
||||||
setDraft(quizz)
|
setDraft(quizz)
|
||||||
@@ -48,6 +85,16 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
|||||||
setDraft(quizz)
|
setDraft(quizz)
|
||||||
setSelectedId(quizz.id)
|
setSelectedId(quizz.id)
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
|
refreshMediaLibrary()
|
||||||
|
})
|
||||||
|
|
||||||
|
useEvent("manager:quizzDeleted", (id) => {
|
||||||
|
toast.success("Quiz deleted")
|
||||||
|
if (selectedId === id) {
|
||||||
|
setSelectedId(null)
|
||||||
|
setDraft(null)
|
||||||
|
}
|
||||||
|
refreshMediaLibrary()
|
||||||
})
|
})
|
||||||
|
|
||||||
useEvent("manager:quizzList", (list) => {
|
useEvent("manager:quizzList", (list) => {
|
||||||
@@ -60,6 +107,31 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
|||||||
setLoading(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) => {
|
const handleLoad = (id: string) => {
|
||||||
setSelectedId(id)
|
setSelectedId(id)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -75,6 +147,13 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDeleteQuizz = () => {
|
||||||
|
if (!selectedId) return
|
||||||
|
if (!window.confirm("Delete this quiz?")) return
|
||||||
|
setSaving(true)
|
||||||
|
socket?.emit("manager:deleteQuizz", { id: selectedId })
|
||||||
|
}
|
||||||
|
|
||||||
const updateQuestion = (
|
const updateQuestion = (
|
||||||
index: number,
|
index: number,
|
||||||
patch: Partial<EditableQuestion>,
|
patch: Partial<EditableQuestion>,
|
||||||
@@ -138,12 +217,236 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
|||||||
setDraft({ ...draft, questions: nextQuestions })
|
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"] | "") => {
|
const handleMediaType = (qIndex: number, type: QuestionMedia["type"] | "") => {
|
||||||
if (!draft) return
|
if (!draft) return
|
||||||
const question = draft.questions[qIndex]
|
const question = draft.questions[qIndex]
|
||||||
|
|
||||||
|
if (type === "") {
|
||||||
|
setQuestionMedia(qIndex, undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const nextMedia =
|
const nextMedia =
|
||||||
type === "" ? undefined : { type, url: question.media?.url || "" }
|
question.media?.type === type
|
||||||
updateQuestion(qIndex, { media: nextMedia })
|
? { ...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 = () => {
|
const handleSave = () => {
|
||||||
@@ -165,7 +468,7 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
|||||||
}, [quizzList, selectedId])
|
}, [quizzList, selectedId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-6xl flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
|
<div className="mx-auto flex w-full max-w-[1280px] flex-col gap-7 rounded-md bg-white p-6 shadow-sm md:p-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button onClick={onBack} className="bg-gray-700">
|
<Button onClick={onBack} className="bg-gray-700">
|
||||||
@@ -174,6 +477,21 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
|||||||
<Button onClick={handleNew} className="bg-blue-600">
|
<Button onClick={handleNew} className="bg-blue-600">
|
||||||
New quiz
|
New quiz
|
||||||
</Button>
|
</Button>
|
||||||
|
{selectedId && (
|
||||||
|
<Button className="bg-red-600" onClick={handleDeleteQuizz} disabled={saving}>
|
||||||
|
Delete quiz
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onBreakToggle && gameId && (
|
||||||
|
<>
|
||||||
|
<Button className="bg-amber-500" onClick={() => onBreakToggle(true)}>
|
||||||
|
Break
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-green-600" onClick={() => onBreakToggle(false)}>
|
||||||
|
Resume
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={handleSave} disabled={saving || loading}>
|
<Button onClick={handleSave} disabled={saving || loading}>
|
||||||
@@ -225,7 +543,13 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{draft.questions.map((question, qIndex) => (
|
{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
|
<div
|
||||||
key={qIndex}
|
key={qIndex}
|
||||||
className="rounded-md border border-gray-200 p-4 shadow-sm"
|
className="rounded-md border border-gray-200 p-4 shadow-sm"
|
||||||
@@ -259,7 +583,9 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<span className="text-sm font-semibold text-gray-600">Cooldown (s)</span>
|
<span className="text-sm font-semibold text-gray-600">
|
||||||
|
Cooldown (s)
|
||||||
|
</span>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={question.cooldown}
|
value={question.cooldown}
|
||||||
@@ -272,7 +598,9 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<span className="text-sm font-semibold text-gray-600">Answer time (s)</span>
|
<span className="text-sm font-semibold text-gray-600">
|
||||||
|
Answer time (s)
|
||||||
|
</span>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={question.time}
|
value={question.time}
|
||||||
@@ -306,29 +634,111 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<label className="flex flex-col gap-1">
|
||||||
<span className="text-sm font-semibold text-gray-600">
|
<span className="text-xs font-semibold text-gray-600">
|
||||||
Media URL
|
Or paste an external URL
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
value={question.media?.url || question.image || ""}
|
value={question.media?.url || question.image || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => handleMediaUrlChange(qIndex, e.target.value)}
|
||||||
updateQuestion(qIndex, {
|
|
||||||
media: question.media
|
|
||||||
? { ...question.media, url: e.target.value }
|
|
||||||
: undefined,
|
|
||||||
image:
|
|
||||||
!question.media || question.media.type === "image"
|
|
||||||
? e.target.value
|
|
||||||
: question.image,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
|
disabled={!question.media?.type}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
Tip: set answer time longer than the clip duration.
|
Tip: set answer time longer than the clip duration.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
@@ -382,7 +792,8 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Button className="bg-blue-600" onClick={addQuestion}>
|
<Button className="bg-blue-600" onClick={addQuestion}>
|
||||||
|
|||||||
@@ -8,9 +8,21 @@ type Props = {
|
|||||||
quizzList: QuizzWithId[]
|
quizzList: QuizzWithId[]
|
||||||
onSelect: (_id: string) => void
|
onSelect: (_id: string) => void
|
||||||
onManage?: () => void
|
onManage?: () => void
|
||||||
|
onMedia?: () => void
|
||||||
|
onTheme?: () => void
|
||||||
|
resumeGameId?: string | null
|
||||||
|
onResume?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const SelectQuizz = ({ quizzList, onSelect, onManage }: Props) => {
|
const SelectQuizz = ({
|
||||||
|
quizzList,
|
||||||
|
onSelect,
|
||||||
|
onManage,
|
||||||
|
onMedia,
|
||||||
|
onTheme,
|
||||||
|
resumeGameId,
|
||||||
|
onResume,
|
||||||
|
}: Props) => {
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleSelect = (id: string) => () => {
|
const handleSelect = (id: string) => () => {
|
||||||
@@ -35,6 +47,31 @@ const SelectQuizz = ({ quizzList, onSelect, onManage }: Props) => {
|
|||||||
<div className="z-10 flex w-full max-w-md flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
|
<div className="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">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Select a quizz</h1>
|
<h1 className="text-2xl font-bold">Select a quizz</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{resumeGameId && onResume && (
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-amber-500 px-3 py-1 text-sm font-semibold text-white shadow"
|
||||||
|
onClick={onResume}
|
||||||
|
>
|
||||||
|
Resume game
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onMedia && (
|
||||||
|
<button
|
||||||
|
className="text-sm font-semibold text-gray-700 underline"
|
||||||
|
onClick={onMedia}
|
||||||
|
>
|
||||||
|
Media
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onTheme && (
|
||||||
|
<button
|
||||||
|
className="text-sm font-semibold text-amber-700 underline"
|
||||||
|
onClick={onTheme}
|
||||||
|
>
|
||||||
|
Theme
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onManage && (
|
{onManage && (
|
||||||
<button
|
<button
|
||||||
className="text-sm font-semibold text-primary underline"
|
className="text-sm font-semibold text-primary underline"
|
||||||
@@ -44,6 +81,7 @@ const SelectQuizz = ({ quizzList, onSelect, onManage }: Props) => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center">
|
||||||
<div className="w-full space-y-2">
|
<div className="w-full space-y-2">
|
||||||
{quizzList.map((quizz) => (
|
{quizzList.map((quizz) => (
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import background from "@rahoot/web/assets/background.webp"
|
||||||
|
import Button from "@rahoot/web/components/Button"
|
||||||
|
import { useThemeStore } from "@rahoot/web/stores/theme"
|
||||||
|
import clsx from "clsx"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
|
||||||
|
type MediaItem = {
|
||||||
|
fileName: string
|
||||||
|
url: string
|
||||||
|
size: number
|
||||||
|
mime: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeEditor = ({ onBack }: Props) => {
|
||||||
|
const { backgroundUrl, brandName, setBackground, setBrandName, reset } = useThemeStore()
|
||||||
|
const [customUrl, setCustomUrl] = useState("")
|
||||||
|
const [items, setItems] = useState<MediaItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||||
|
const [savingTheme, setSavingTheme] = useState(false)
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
const [initializing, setInitializing] = useState(true)
|
||||||
|
|
||||||
|
const previewUrl = useMemo(
|
||||||
|
() => backgroundUrl || customUrl || background.src,
|
||||||
|
[backgroundUrl, customUrl],
|
||||||
|
)
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [mediaRes, themeRes] = await Promise.all([
|
||||||
|
fetch("/api/media", { cache: "no-store" }),
|
||||||
|
fetch("/api/theme", { cache: "no-store" }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const mediaData = await mediaRes.json()
|
||||||
|
const themeData = await themeRes.json()
|
||||||
|
|
||||||
|
if (!mediaRes.ok) throw new Error(mediaData.error || "Failed to load media")
|
||||||
|
if (!themeRes.ok) throw new Error(themeData.error || "Failed to load theme")
|
||||||
|
|
||||||
|
const onlyImages = (mediaData.media || []).filter(
|
||||||
|
(item: MediaItem) => item.mime?.startsWith("image/"),
|
||||||
|
)
|
||||||
|
setItems(onlyImages)
|
||||||
|
|
||||||
|
if (themeData.theme) {
|
||||||
|
if (typeof themeData.theme.backgroundUrl === "string") {
|
||||||
|
setBackground(themeData.theme.backgroundUrl || null)
|
||||||
|
setCustomUrl(themeData.theme.backgroundUrl || "")
|
||||||
|
}
|
||||||
|
if (typeof themeData.theme.brandName === "string") {
|
||||||
|
setBrandName(themeData.theme.brandName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setInitializing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const persistTheme = async (next: { backgroundUrl?: string | null; brandName?: string }) => {
|
||||||
|
setSavingTheme(true)
|
||||||
|
setSaveError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/theme", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(next),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || "Failed to save theme")
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to save theme"
|
||||||
|
setSaveError(message)
|
||||||
|
} finally {
|
||||||
|
setSavingTheme(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSet = (url: string) => {
|
||||||
|
if (!url) return
|
||||||
|
setBackground(url)
|
||||||
|
persistTheme({ backgroundUrl: url })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyCustom = () => {
|
||||||
|
if (!customUrl.trim()) return
|
||||||
|
handleSet(customUrl.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpload = async (file?: File | null) => {
|
||||||
|
if (!file) return
|
||||||
|
setUploading(true)
|
||||||
|
setUploadError(null)
|
||||||
|
try {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append("file", file)
|
||||||
|
const res = await fetch("/api/media", {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || "Upload failed")
|
||||||
|
}
|
||||||
|
if (data.media?.url) {
|
||||||
|
handleSet(data.media.url)
|
||||||
|
setCustomUrl(data.media.url)
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Upload failed"
|
||||||
|
setUploadError(message)
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
reset()
|
||||||
|
setCustomUrl("")
|
||||||
|
persistTheme({ backgroundUrl: null, brandName: "Rahoot" })
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div className="flex flex-col leading-tight">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Theme editor</h2>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Set the game background from uploads or a custom URL.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div className="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800">Branding</h3>
|
||||||
|
<label className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold text-gray-600">
|
||||||
|
Brand name
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={brandName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBrandName(e.target.value)
|
||||||
|
persistTheme({ brandName: e.target.value })
|
||||||
|
}}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
placeholder="e.g., Kwalitaria Pub Quiz"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Appears in the browser title bar and elsewhere we surface the brand.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800">Preview</h3>
|
||||||
|
<div className="relative h-60 w-full overflow-hidden rounded-md border border-gray-100 bg-gray-50">
|
||||||
|
<Image
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Background preview"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="100vw"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/20" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-white">
|
||||||
|
<span className="rounded bg-black/50 px-3 py-1 text-sm font-semibold">
|
||||||
|
Current background
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button className="bg-gray-700" onClick={handleReset}>
|
||||||
|
Reset to default
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-primary" onClick={handleApplyCustom}>
|
||||||
|
Apply custom URL
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={customUrl}
|
||||||
|
onChange={(e) => setCustomUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/background.webp or /media/your-file"
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Paste any reachable image URL (including your uploaded media path). Changes apply immediately to the game background.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800">Images from media library</h3>
|
||||||
|
<p className="text-sm text-gray-500">Pick any uploaded image as the background.</p>
|
||||||
|
</div>
|
||||||
|
<Button className="bg-gray-700" onClick={load} disabled={loading}>
|
||||||
|
{loading ? "Refreshing..." : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-dashed border-gray-300 p-3">
|
||||||
|
<label className="flex items-center gap-3 text-sm font-semibold text-gray-800">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="text-sm"
|
||||||
|
onChange={(e) => handleUpload(e.target.files?.[0])}
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
|
{uploading ? "Uploading..." : "Upload image"}
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Upload a background image (webp/png/jpg). It will be available immediately and selected as the current background.
|
||||||
|
</p>
|
||||||
|
{uploadError && (
|
||||||
|
<p className="mt-1 text-xs font-semibold text-red-600">{uploadError}</p>
|
||||||
|
)}
|
||||||
|
{saveError && (
|
||||||
|
<p className="mt-1 text-xs font-semibold text-red-600">{saveError}</p>
|
||||||
|
)}
|
||||||
|
{(savingTheme || initializing) && !uploading && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{initializing ? "Loading theme…" : "Saving theme…"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.fileName}
|
||||||
|
className={clsx(
|
||||||
|
"relative h-32 overflow-hidden rounded-md border border-gray-200 bg-gray-50 text-left shadow-sm transition hover:border-primary",
|
||||||
|
backgroundUrl === item.url && "ring-2 ring-primary",
|
||||||
|
)}
|
||||||
|
onClick={() => handleSet(item.url)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={item.url}
|
||||||
|
alt={item.fileName}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="200px"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-black/0" />
|
||||||
|
<div className="absolute bottom-2 left-2 right-2 text-xs font-semibold text-white drop-shadow">
|
||||||
|
{item.fileName}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!loading && items.length === 0 && (
|
||||||
|
<div className="rounded-md border border-dashed border-gray-300 p-4 text-sm text-gray-500">
|
||||||
|
No images uploaded yet. Upload an image in the Media page, then pick it here.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeEditor
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { PropsWithChildren } from "react"
|
||||||
|
|
||||||
|
const BrandShell = ({ children }: PropsWithChildren) => (
|
||||||
|
<div className="flex w-full max-w-xl flex-col items-center justify-center gap-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default BrandShell
|
||||||
@@ -3,6 +3,7 @@ import Form from "@rahoot/web/components/Form"
|
|||||||
import Input from "@rahoot/web/components/Input"
|
import Input from "@rahoot/web/components/Input"
|
||||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||||
import { usePlayerStore } from "@rahoot/web/stores/player"
|
import { usePlayerStore } from "@rahoot/web/stores/player"
|
||||||
|
import BrandShell from "@rahoot/web/components/game/join/BrandShell"
|
||||||
import { KeyboardEvent, useState } from "react"
|
import { KeyboardEvent, useState } from "react"
|
||||||
|
|
||||||
const Room = () => {
|
const Room = () => {
|
||||||
@@ -25,6 +26,7 @@ const Room = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<BrandShell>
|
||||||
<Form>
|
<Form>
|
||||||
<Input
|
<Input
|
||||||
onChange={(e) => setInvitation(e.target.value)}
|
onChange={(e) => setInvitation(e.target.value)}
|
||||||
@@ -33,6 +35,7 @@ const Room = () => {
|
|||||||
/>
|
/>
|
||||||
<Button onClick={handleJoin}>Submit</Button>
|
<Button onClick={handleJoin}>Submit</Button>
|
||||||
</Form>
|
</Form>
|
||||||
|
</BrandShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { STATUS } from "@rahoot/common/types/game/status"
|
|||||||
import Button from "@rahoot/web/components/Button"
|
import Button from "@rahoot/web/components/Button"
|
||||||
import Form from "@rahoot/web/components/Form"
|
import Form from "@rahoot/web/components/Form"
|
||||||
import Input from "@rahoot/web/components/Input"
|
import Input from "@rahoot/web/components/Input"
|
||||||
|
import BrandShell from "@rahoot/web/components/game/join/BrandShell"
|
||||||
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
|
||||||
import { usePlayerStore } from "@rahoot/web/stores/player"
|
import { usePlayerStore } from "@rahoot/web/stores/player"
|
||||||
|
|
||||||
@@ -33,11 +34,16 @@ const Username = () => {
|
|||||||
useEvent("game:successJoin", (gameId) => {
|
useEvent("game:successJoin", (gameId) => {
|
||||||
setStatus(STATUS.WAIT, { text: "Waiting for the players" })
|
setStatus(STATUS.WAIT, { text: "Waiting for the players" })
|
||||||
login(username)
|
login(username)
|
||||||
|
try {
|
||||||
|
localStorage.setItem("last_game_id", gameId)
|
||||||
|
localStorage.setItem("last_username", username)
|
||||||
|
} catch {}
|
||||||
|
|
||||||
router.replace(`/game/${gameId}`)
|
router.replace(`/game/${gameId}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<BrandShell>
|
||||||
<Form>
|
<Form>
|
||||||
<Input
|
<Input
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
@@ -46,6 +52,7 @@ const Username = () => {
|
|||||||
/>
|
/>
|
||||||
<Button onClick={handleLogin}>Submit</Button>
|
<Button onClick={handleLogin}>Submit</Button>
|
||||||
</Form>
|
</Form>
|
||||||
|
</BrandShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const Answers = ({
|
|||||||
const { player } = usePlayerStore()
|
const { player } = usePlayerStore()
|
||||||
|
|
||||||
const [cooldown, setCooldown] = useState(time)
|
const [cooldown, setCooldown] = useState(time)
|
||||||
|
const [paused, setPaused] = useState(false)
|
||||||
const [totalAnswer, setTotalAnswer] = useState(0)
|
const [totalAnswer, setTotalAnswer] = useState(0)
|
||||||
const [isMediaPlaying, setIsMediaPlaying] = useState(false)
|
const [isMediaPlaying, setIsMediaPlaying] = useState(false)
|
||||||
|
|
||||||
@@ -78,6 +79,10 @@ const Answers = ({
|
|||||||
setCooldown(sec)
|
setCooldown(sec)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEvent("game:cooldownPause", (isPaused) => {
|
||||||
|
setPaused(isPaused)
|
||||||
|
})
|
||||||
|
|
||||||
useEvent("game:playerAnswer", (count) => {
|
useEvent("game:playerAnswer", (count) => {
|
||||||
setTotalAnswer(count)
|
setTotalAnswer(count)
|
||||||
sfxPop()
|
sfxPop()
|
||||||
@@ -102,6 +107,11 @@ const Answers = ({
|
|||||||
<div className="flex flex-col items-center rounded-full bg-black/40 px-4 text-lg font-bold">
|
<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 className="translate-y-1 text-sm">Time</span>
|
||||||
<span>{cooldown}</span>
|
<span>{cooldown}</span>
|
||||||
|
{paused && (
|
||||||
|
<span className="text-xs font-semibold uppercase text-amber-200">
|
||||||
|
Paused
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center rounded-full bg-black/40 px-4 text-lg font-bold">
|
<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>
|
<span className="translate-y-1 text-sm">Answers</span>
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
|
import { CommonStatusDataMap } from "@rahoot/common/types/game/status"
|
||||||
import QuestionMedia from "@rahoot/web/components/game/QuestionMedia"
|
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 { SFX_SHOW_SOUND } from "@rahoot/web/utils/constants"
|
||||||
import { useEffect } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import useSound from "use-sound"
|
import useSound from "use-sound"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -12,11 +13,23 @@ type Props = {
|
|||||||
|
|
||||||
const Question = ({ data: { question, image, media, cooldown } }: Props) => {
|
const Question = ({ data: { question, image, media, cooldown } }: Props) => {
|
||||||
const [sfxShow] = useSound(SFX_SHOW_SOUND, { volume: 0.5 })
|
const [sfxShow] = useSound(SFX_SHOW_SOUND, { volume: 0.5 })
|
||||||
|
const [seconds, setSeconds] = useState(cooldown)
|
||||||
|
const [paused, setPaused] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sfxShow()
|
sfxShow()
|
||||||
}, [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 (
|
return (
|
||||||
<section className="relative mx-auto flex h-full w-full max-w-7xl flex-1 flex-col items-center px-4">
|
<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">
|
<div className="flex flex-1 flex-col items-center justify-center gap-5">
|
||||||
@@ -29,10 +42,17 @@ const Question = ({ data: { question, image, media, cooldown } }: Props) => {
|
|||||||
alt={question}
|
alt={question}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mb-20 h-4 w-full max-w-4xl self-start overflow-hidden rounded-full bg-white/30">
|
||||||
<div
|
<div
|
||||||
className="bg-primary mb-20 h-4 self-start justify-self-end rounded-full"
|
className="h-full bg-primary transition-[width]"
|
||||||
style={{ animation: `progressBar ${cooldown}s linear forwards` }}
|
style={{ width: `${percent}%` }}
|
||||||
></div>
|
/>
|
||||||
|
</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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const Responses = ({
|
|||||||
const [percentages, setPercentages] = useState<Record<string, string>>({})
|
const [percentages, setPercentages] = useState<Record<string, string>>({})
|
||||||
const [isMusicPlaying, setIsMusicPlaying] = useState(false)
|
const [isMusicPlaying, setIsMusicPlaying] = useState(false)
|
||||||
const [isMediaPlaying, setIsMediaPlaying] = useState(false)
|
const [isMediaPlaying, setIsMediaPlaying] = useState(false)
|
||||||
|
const correctSet = Array.isArray(correct) ? correct : [correct]
|
||||||
|
|
||||||
const [sfxResults] = useSound(SFX_RESULTS_SOUND, {
|
const [sfxResults] = useSound(SFX_RESULTS_SOUND, {
|
||||||
volume: 0.2,
|
volume: 0.2,
|
||||||
@@ -81,39 +82,87 @@ const Responses = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`mt-8 grid h-40 w-full max-w-3xl gap-4 px-2`}
|
className={`mt-8 grid h-48 w-full max-w-5xl gap-4 px-2`}
|
||||||
style={{ gridTemplateColumns: `repeat(${answers.length}, 1fr)` }}
|
style={{ gridTemplateColumns: `repeat(${answers.length}, 1fr)` }}
|
||||||
>
|
>
|
||||||
{answers.map((_, key) => (
|
{answers.map((label, key) => {
|
||||||
|
const votes = responses[key] || 0
|
||||||
|
const percent = percentages[key] || "0%"
|
||||||
|
const isCorrect = correctSet.includes(key)
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col justify-end self-end overflow-hidden rounded-md",
|
"relative flex flex-col justify-end self-end overflow-hidden rounded-md border shadow",
|
||||||
|
isCorrect ? "border-green-400 ring-4 ring-green-300/50" : "border-white/40",
|
||||||
ANSWERS_COLORS[key],
|
ANSWERS_COLORS[key],
|
||||||
)}
|
)}
|
||||||
style={{ height: percentages[key] }}
|
style={{ height: percent }}
|
||||||
|
title={label}
|
||||||
>
|
>
|
||||||
<span className="w-full bg-black/10 text-center text-lg font-bold text-white drop-shadow-md">
|
<div className="absolute inset-0 bg-black/20" />
|
||||||
{responses[key] || 0}
|
<div className="relative flex w-full flex-col items-center gap-1 bg-black/25 px-2 py-2 text-white">
|
||||||
|
<span className="text-sm font-semibold">{label}</span>
|
||||||
|
<span className="text-lg font-bold">{votes} ({percent})</span>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full px-3 py-0.5 text-xs font-bold uppercase",
|
||||||
|
isCorrect ? "bg-green-500 text-white" : "bg-black/40 text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCorrect ? "Correct" : "Not correct"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mx-auto mb-4 grid w-full max-w-7xl grid-cols-2 gap-1 rounded-full px-2 text-lg font-bold text-white md:text-xl">
|
<div className="mx-auto mb-6 grid w-full max-w-7xl grid-cols-2 gap-2 px-2 text-lg font-bold text-white md:text-xl">
|
||||||
{answers.map((answer, key) => (
|
{answers.map((answer, key) => {
|
||||||
|
const votes = responses[key] || 0
|
||||||
|
const totalVotes = Object.values(responses).reduce(
|
||||||
|
(acc, val) => acc + (val || 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const percent = totalVotes ? Math.round((votes / totalVotes) * 100) : 0
|
||||||
|
const isCorrect = correctSet.includes(key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex flex-col gap-2 rounded-md bg-white/10 p-2 shadow">
|
||||||
<AnswerButton
|
<AnswerButton
|
||||||
key={key}
|
className={clsx(
|
||||||
className={clsx(ANSWERS_COLORS[key], {
|
ANSWERS_COLORS[key],
|
||||||
"opacity-65": responses && correct !== key,
|
"w-full justify-between",
|
||||||
})}
|
!isCorrect && "opacity-70",
|
||||||
|
)}
|
||||||
icon={ANSWERS_ICONS[key]}
|
icon={ANSWERS_ICONS[key]}
|
||||||
>
|
>
|
||||||
{answer}
|
<span>{answer}</span>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full px-3 py-0.5 text-sm font-bold",
|
||||||
|
isCorrect ? "bg-green-500 text-white" : "bg-black/30 text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCorrect ? "Correct" : "Wrong"}
|
||||||
|
</span>
|
||||||
</AnswerButton>
|
</AnswerButton>
|
||||||
))}
|
<div className="flex items-center justify-between text-sm text-gray-100">
|
||||||
|
<span>
|
||||||
|
Votes: <strong>{votes}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{percent}
|
||||||
|
%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Config from "@rahoot/socket/services/config"
|
||||||
|
|
||||||
|
export type ThemeSettings = {
|
||||||
|
brandName: string
|
||||||
|
backgroundUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTheme = (): ThemeSettings => {
|
||||||
|
const theme = Config.theme()
|
||||||
|
return {
|
||||||
|
brandName: theme.brandName || "Rahoot",
|
||||||
|
backgroundUrl:
|
||||||
|
typeof theme.backgroundUrl === "string" && theme.backgroundUrl.length > 0
|
||||||
|
? theme.backgroundUrl
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveTheme = (payload: Partial<ThemeSettings>): ThemeSettings => {
|
||||||
|
const current = getTheme()
|
||||||
|
const merged = {
|
||||||
|
brandName: payload.brandName ?? current.brandName,
|
||||||
|
backgroundUrl:
|
||||||
|
payload.backgroundUrl === undefined
|
||||||
|
? current.backgroundUrl
|
||||||
|
: payload.backgroundUrl,
|
||||||
|
}
|
||||||
|
return Config.saveTheme(merged)
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ type ManagerStore<T> = {
|
|||||||
setGameId: (_gameId: string | null) => void
|
setGameId: (_gameId: string | null) => void
|
||||||
setStatus: <K extends keyof T>(_name: K, _data: T[K]) => void
|
setStatus: <K extends keyof T>(_name: K, _data: T[K]) => void
|
||||||
resetStatus: () => void
|
resetStatus: () => void
|
||||||
setPlayers: (_players: Player[]) => void
|
setPlayers: (_players: Player[] | ((_prev: Player[]) => Player[])) => void
|
||||||
|
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}
|
}
|
||||||
@@ -25,12 +25,29 @@ const initialState = {
|
|||||||
export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({
|
export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
|
|
||||||
setGameId: (gameId) => set({ gameId }),
|
setGameId: (gameId) => {
|
||||||
|
try {
|
||||||
|
if (gameId) {
|
||||||
|
localStorage.setItem("last_manager_game_id", gameId)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("last_manager_game_id")
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
set({ gameId })
|
||||||
|
},
|
||||||
|
|
||||||
setStatus: (name, data) => set({ status: createStatus(name, data) }),
|
setStatus: (name, data) => set({ status: createStatus(name, data) }),
|
||||||
resetStatus: () => set({ status: null }),
|
resetStatus: () => set({ status: null }),
|
||||||
|
|
||||||
setPlayers: (players) => set({ players }),
|
setPlayers: (players) =>
|
||||||
|
set((state) => ({
|
||||||
|
players: typeof players === "function" ? players(state.players) : players,
|
||||||
|
})),
|
||||||
|
|
||||||
reset: () => set(initialState),
|
reset: () => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem("last_manager_game_id")
|
||||||
|
} catch {}
|
||||||
|
set(initialState)
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -35,11 +35,24 @@ export const usePlayerStore = create<PlayerStore<StatusDataMap>>((set) => ({
|
|||||||
|
|
||||||
setGameId: (gameId) => set({ gameId }),
|
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) =>
|
login: (username) =>
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem("last_username", username)
|
||||||
|
} catch {}
|
||||||
|
return {
|
||||||
player: { ...state.player, username },
|
player: { ...state.player, username },
|
||||||
})),
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
join: (gameId) => {
|
join: (gameId) => {
|
||||||
set((state) => ({
|
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) => ({
|
set((state) => ({
|
||||||
player: { ...state.player, points },
|
player: { ...state.player, points },
|
||||||
})),
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
setStatus: (name, data) => set({ status: createStatus(name, data) }),
|
setStatus: (name, data) => set({ status: createStatus(name, data) }),
|
||||||
|
|
||||||
reset: () => set(initialState),
|
reset: () => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem("last_username")
|
||||||
|
localStorage.removeItem("last_points")
|
||||||
|
} catch {}
|
||||||
|
set(initialState)
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { create } from "zustand"
|
||||||
|
import { persist } from "zustand/middleware"
|
||||||
|
|
||||||
|
type ThemeState = {
|
||||||
|
backgroundUrl: string | null
|
||||||
|
brandName: string
|
||||||
|
setBackground: (_url: string | null) => void
|
||||||
|
setBrandName: (_name: string) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useThemeStore = create<ThemeState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
backgroundUrl: null,
|
||||||
|
brandName: "Rahoot",
|
||||||
|
setBackground: (backgroundUrl) => set({ backgroundUrl }),
|
||||||
|
setBrandName: (brandName) => set({ brandName }),
|
||||||
|
reset: () => set({ backgroundUrl: null, brandName: "Rahoot" }),
|
||||||
|
}),
|
||||||
|
{ name: "theme-preferences" },
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -66,7 +66,7 @@ export const MANAGER_SKIP_BTN = {
|
|||||||
[STATUS.SHOW_ROOM]: "Start Game",
|
[STATUS.SHOW_ROOM]: "Start Game",
|
||||||
[STATUS.SHOW_START]: null,
|
[STATUS.SHOW_START]: null,
|
||||||
[STATUS.SHOW_PREPARED]: null,
|
[STATUS.SHOW_PREPARED]: null,
|
||||||
[STATUS.SHOW_QUESTION]: null,
|
[STATUS.SHOW_QUESTION]: "Skip",
|
||||||
[STATUS.SELECT_ANSWER]: "Skip",
|
[STATUS.SELECT_ANSWER]: "Skip",
|
||||||
[STATUS.SHOW_RESULT]: null,
|
[STATUS.SHOW_RESULT]: null,
|
||||||
[STATUS.SHOW_RESPONSES]: "Next",
|
[STATUS.SHOW_RESPONSES]: "Next",
|
||||||
|
|||||||
Generated
+93
-5
@@ -54,6 +54,9 @@ importers:
|
|||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.18
|
specifier: ^1.11.18
|
||||||
version: 1.11.18
|
version: 1.11.18
|
||||||
|
redis:
|
||||||
|
specifier: ^4.6.13
|
||||||
|
version: 4.7.1
|
||||||
socket.io:
|
socket.io:
|
||||||
specifier: ^4.8.1
|
specifier: ^4.8.1
|
||||||
version: 4.8.1
|
version: 4.8.1
|
||||||
@@ -693,6 +696,35 @@ packages:
|
|||||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||||
engines: {node: '>=12.4.0'}
|
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':
|
'@rtsao/scc@1.1.0':
|
||||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||||
|
|
||||||
@@ -1145,6 +1177,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
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:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -1529,6 +1565,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
gensync@1.0.0-beta.2:
|
||||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -1962,6 +2002,7 @@ packages:
|
|||||||
next@15.5.4:
|
next@15.5.4:
|
||||||
resolution: {integrity: sha512-xH4Yjhb82sFYQfY3vbkJfgSDgXvBB6a8xPs9i35k6oZJRoQRihZH+4s9Yo2qsWpzBmZ3lPXaJ2KPXLfkvW4LnA==}
|
resolution: {integrity: sha512-xH4Yjhb82sFYQfY3vbkJfgSDgXvBB6a8xPs9i35k6oZJRoQRihZH+4s9Yo2qsWpzBmZ3lPXaJ2KPXLfkvW4LnA==}
|
||||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
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
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': ^1.1.0
|
'@opentelemetry/api': ^1.1.0
|
||||||
@@ -2177,6 +2218,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
|
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
redis@4.7.1:
|
||||||
|
resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==}
|
||||||
|
|
||||||
reflect.getprototypeof@1.0.10:
|
reflect.getprototypeof@1.0.10:
|
||||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2506,6 +2550,9 @@ packages:
|
|||||||
yallist@3.1.1:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
|
yallist@4.0.0:
|
||||||
|
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
|
||||||
|
|
||||||
yocto-queue@0.1.0:
|
yocto-queue@0.1.0:
|
||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2957,6 +3004,32 @@ snapshots:
|
|||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@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': {}
|
'@rtsao/scc@1.1.0': {}
|
||||||
|
|
||||||
'@rushstack/eslint-patch@1.14.1': {}
|
'@rushstack/eslint-patch@1.14.1': {}
|
||||||
@@ -3393,6 +3466,8 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
cluster-key-slot@1.1.2: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
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)
|
'@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-import-resolver-node: 0.3.9
|
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-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-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))
|
eslint-plugin-react: 7.37.5(eslint@9.38.0(jiti@2.6.1))
|
||||||
@@ -3694,7 +3769,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
'@nolyfill/is-core-module': 1.0.39
|
'@nolyfill/is-core-module': 1.0.39
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@@ -3709,14 +3784,14 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
debug: 3.2.7
|
debug: 3.2.7
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@typescript-eslint/parser': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)
|
'@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-import-resolver-node: 0.3.9
|
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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -3731,7 +3806,7 @@ snapshots:
|
|||||||
doctrine: 2.1.0
|
doctrine: 2.1.0
|
||||||
eslint: 9.38.0(jiti@2.6.1)
|
eslint: 9.38.0(jiti@2.6.1)
|
||||||
eslint-import-resolver-node: 0.3.9
|
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
|
hasown: 2.0.2
|
||||||
is-core-module: 2.16.1
|
is-core-module: 2.16.1
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
@@ -3953,6 +4028,8 @@ snapshots:
|
|||||||
|
|
||||||
generator-function@2.0.1: {}
|
generator-function@2.0.1: {}
|
||||||
|
|
||||||
|
generic-pool@3.9.0: {}
|
||||||
|
|
||||||
gensync@1.0.0-beta.2: {}
|
gensync@1.0.0-beta.2: {}
|
||||||
|
|
||||||
get-intrinsic@1.3.0:
|
get-intrinsic@1.3.0:
|
||||||
@@ -4499,6 +4576,15 @@ snapshots:
|
|||||||
|
|
||||||
react@19.1.0: {}
|
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:
|
reflect.getprototypeof@1.0.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.8
|
call-bind: 1.0.8
|
||||||
@@ -4967,6 +5053,8 @@ snapshots:
|
|||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
|
yallist@4.0.0: {}
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
yup@1.7.1:
|
yup@1.7.1:
|
||||||
|
|||||||
Reference in New Issue
Block a user